From 441778aa9d8712759a646c758600e5265b936f34 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 23 Jun 2023 12:44:57 +0200 Subject: [PATCH 001/347] CM-23217 - Sign and notarize the macOS CLI executable (#124) --- .github/workflows/build_executable.yml | 48 ++++++++++++++++++++++++++ entitlements.plist | 14 ++++++++ 2 files changed, 62 insertions(+) create mode 100644 entitlements.plist diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 04177e6d..ed522efd 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -53,6 +53,54 @@ jobs: - name: Test executable run: ./dist/cycode --version + - name: Sign macOS executable + if: ${{ startsWith(matrix.os, 'macos') }} + env: + APPLE_CERT: ${{ secrets.APPLE_CERT }} + APPLE_CERT_PWD: ${{ secrets.APPLE_CERT_PWD }} + APPLE_CERT_NAME: ${{ secrets.APPLE_CERT_NAME }} + APPLE_KEYCHAIN_PASSWORD: ${{ secrets.APPLE_KEYCHAIN_PASSWORD }} + run: | + # import certificate + CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 + echo -n "$APPLE_CERT" | base64 --decode -o $CERTIFICATE_PATH + + # create temporary keychain + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + security create-keychain -p "$APPLE_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + security set-keychain-settings -lut 21600 $KEYCHAIN_PATH + security unlock-keychain -p "$APPLE_KEYCHAIN_PASSWORD" $KEYCHAIN_PATH + + # import certificate to keychain + 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: Notarize macOS executable + if: ${{ startsWith(matrix.os, 'macos') }} + env: + APPLE_NOTARIZATION_EMAIL: ${{ secrets.APPLE_NOTARIZATION_EMAIL }} + APPLE_NOTARIZATION_PWD: ${{ secrets.APPLE_NOTARIZATION_PWD }} + APPLE_NOTARIZATION_TEAM_ID: ${{ secrets.APPLE_NOTARIZATION_TEAM_ID }} + run: | + # 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) + ditto -c -k --keepParent dist/cycode 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 + + - name: Test signed executable + if: ${{ startsWith(matrix.os, 'macos') }} + run: ./dist/cycode --version + - uses: actions/upload-artifact@v3 with: name: cycode-cli-${{ matrix.os }} diff --git a/entitlements.plist b/entitlements.plist new file mode 100644 index 00000000..fe2e16cb --- /dev/null +++ b/entitlements.plist @@ -0,0 +1,14 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + + \ No newline at end of file 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 002/347] 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 003/347] 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 004/347] 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 005/347] 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 006/347] 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 007/347] 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 008/347] 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 009/347] 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 010/347] 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 011/347] 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 012/347] 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 013/347] 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 014/347] 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 015/347] 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 016/347] 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 017/347] 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 018/347] 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 019/347] 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 020/347] 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 021/347] 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 022/347] 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 023/347] 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 024/347] 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 025/347] 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 026/347] 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 027/347] 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 028/347] 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 029/347] 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 030/347] 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 031/347] 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 032/347] 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 033/347] 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 034/347] 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 035/347] 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 036/347] 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 037/347] 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 038/347] 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 039/347] 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 040/347] 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 041/347] 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 042/347] 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 043/347] 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 044/347] 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 045/347] 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 046/347] 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 047/347] 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 048/347] 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 049/347] 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 050/347] 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 051/347] 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 052/347] 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 053/347] 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 054/347] 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 055/347] 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 056/347] 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 057/347] 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 058/347] 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 059/347] 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 060/347] 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 061/347] 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 062/347] 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 063/347] 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 064/347] 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 065/347] 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 066/347] 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 067/347] 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 068/347] 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 069/347] 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 070/347] 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 071/347] 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 072/347] 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 073/347] 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 074/347] 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 075/347] 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 076/347] 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 077/347] 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 078/347] 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 079/347] 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 080/347] 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 081/347] 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 082/347] 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 083/347] 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 084/347] 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 085/347] 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 086/347] 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 087/347] 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 088/347] 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 089/347] 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 090/347] 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 091/347] 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 092/347] 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 093/347] 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 094/347] 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 095/347] 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 096/347] 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 097/347] 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 098/347] 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 099/347] 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 100/347] 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 101/347] 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 102/347] 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 103/347] 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 104/347] 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 105/347] 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 106/347] 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 107/347] 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 108/347] 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 109/347] 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 110/347] =?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 111/347] 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 112/347] 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 113/347] 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 114/347] 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 115/347] 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 116/347] 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 117/347] 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 118/347] 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 119/347] 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 120/347] 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 121/347] 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 122/347] 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 123/347] 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 124/347] 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 125/347] 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 126/347] 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 127/347] 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 128/347] 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 129/347] 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 130/347] 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 131/347] 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 132/347] 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 133/347] 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 134/347] 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 135/347] 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 136/347] 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 137/347] 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 138/347] 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 139/347] 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 140/347] 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 141/347] 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 142/347] 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 143/347] 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 144/347] 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 145/347] 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 146/347] 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 147/347] 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 148/347] 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 149/347] 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 150/347] 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 151/347] 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 152/347] 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 153/347] 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 154/347] 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 155/347] 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 156/347] 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 157/347] 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 158/347] 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 159/347] 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 160/347] 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 161/347] 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 162/347] 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 163/347] 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 164/347] 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 165/347] 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 166/347] 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 167/347] 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 168/347] 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 169/347] 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 170/347] 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 171/347] 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 172/347] 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 173/347] 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 174/347] 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 175/347] 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 176/347] 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 zcmdSAcTiJbzyB+uf&xNB=@0=0RC-53BGRRasDLy95s)TLLQ5MZ?6?Z^Dd3ahbK&2P9eaMvKwI-J+-@b4sY+0X5~;;>;e_T4I{KoB zvCf;Ehab-z@6`sK=Qy7*nZA`3{^rI_7UA%77sJmoT={4a-iQ^&NVN=BS#L3c9g<5sshur0jUEJ;{^!|i~(Jn>l;~l4ezHpm=XVE?Lobjx_ zH}-JnQA=PlAzr;Gnf`)FNVSDr>~e~g=gqTGw}Pd|?_Ub|{g$IlT06hH*ZVP2C~Y!i zz5ZA9x@6eFPUct2kV1XPu1UxWMlYaLA3d|#DT6<3?v%mW#GM2E_bWlDeUC)wTSx5H zLXaih*O%w2!{v(`_Mb>0CIW^sFEy0jC1Yo!uYIlEva}0XEidcQ<uc;$88#)*mi$ICWCMESo5 z`QLvm%AdzX{l|s%Q$C`M|Kq~iHLN-w|8ZTBUS|nM&i}sP|M??9TI+uuGVjrl^BgF9 zrTDMuVlhP~GXH+XUQSnY+|E{?X+gSogPI0yk1+7rt)q6mA$<)?r)@Q{$lT4n-o@`- zmmn1U_v6;|EA~$XS;1fVG>qZI%)rgDYwPDW{?pqxgqr1|PGaC6 zO{ROfOA$l%$ogmWp2^_jXs)5(hauh|At4yFewWlXU|Vy+8?on5L(Wg>C3|CS}`(7*Yd)Owp=jk-i`}h!IW(+HuruE49!CLroN$V`CBse#r*qPZJmE`9wW>*7DUYK zgOGnG25&FDTS#?^FQY7^7pP2ymewpQ&U{Mux@AwDAa|3yitsFjcZ!K zpjBV2LU-zYE9ZAh(tY88@d<~`u-LadPjY!9w;Ub>?`o z9x?d!`P#SA?haxlZGYJ!cj={Hx0-m^ZtawD>2G;M=~DZ{C5r*u2XjTWYaR1`8;wlB zcF?$%5#rT=EV$P6ttG4$SJKxPwL3NC`5Ky{L3xz+#WcIDlTGD*ZjE4WR`AuX;bQy! ziO*)`p592R&jadjg+A&lrphZg_34VSJH-uQep5FErOdY&PsLMDl=<=ycdWJdA$31qU0TYztg#Zh@0#PUnXSJ5dr_rkmb}#nvxf_Rw@g|D zIknz(gcbyv?@uWv+Wn7>0xi%1js5wkRjTSrYT?G)6(w7HRZ~N1S1StFt{dN&Yavxm z1}{Ik8`*fkI}`~=h3-xv>7f(03_|#-2G5F-Pk{M)hiy6RuPqk1FtjQU*`@DPEq>Z6 zC@k*Ii5)q0_P5>Wr%UkD=>nu#z+~}dfH(VuJzVv+!bRvm_3dSv(|YewH4`!LIVyFW zyexfj#Tq{Iy3a_VD~Yz5f}J~9|E+-kwl*lDG6{E*&Yq&}R1SRh!tdg*Uo##mwjW*j zTG;#8!tUK;u%d*qJynRd(L7*Vdol$|d9)OO1G&{M7ZvLUsAoy970P789rvI0q}0vq41cT^GLpBPl^}CZnYAw@=rci_C2)53wFRFth zF4>uQA|6Te^UMxes}^4^AF^{RAGmMGBm{pLJeQ#N#AkJ8z{1{`+^z1t--AZnjq4ak z9IRT!ULfwSBk8qVZRP8LGg-_}vFpHQhi?9o+)yXPDr~M3&@gEB*gFfm_du|i%4bs^ zzG#a8>~PCwYOx?YYw&^lcgdYIXh$1&>$<}pJ-3g2CWiJJlsQG(;V!v?EEJ}=z0@cE zJ%H5W8GTm>h)a^m%}x()90~@_C5&*617V1K?ng%F&X{-hvk~W%s2w&hzzSD*(VHWI zQ(=B#DZ-|8v7j{0_deb}uo>gn9|XiPNQozKj9dNoV5jrusy9+Sn~A9Kbo%Fm(M?LG zZ^xDSZwg1|t4)j?mQvjrcIty3tR(8j@x9Y|$UMIXyD<%KIxYT)-5*BzW!lSS?U6w1 ziMGsF^QxXt4n+tcfdJg;`T2K@Ohf8IntM?h5C~1m>ZLY*>QcVduGLhBay02sScM>} zm8PTC)!opmYwxw#H58{xC3i=$z&0`ISJ)*!PWQa;zT9}^ddd#gYKc2K57tOmQELct z`qZPwnYrhvRTdpv=+LE`*9$+V+v$Nb2Wz)_N1-YZdg$m*&2sNmQN2&~G33zCm%1UL zzf;S|Y$}h7o%n5@S?#Z0u$ZHFj&D%?%C`>AFLus$H~a4M_o8wpzS-y5ulE}EwwOZ9 zVqAW8(E_Iu?_;N5W-!^!s$y65kaoI5upwVsS<^gbJIel0W5j_c%)P>GD~Kg}be z!;>nep9@}j)oR$Y7^0gPTjVMw_P;9J@;t#X9;#FOz<)STq(UpY0aWa7GgT~8tN7jC zW-8y*C=?lsSR2_IaxdF2liOUZasWc5Otzq{L)`ODD46h!A-t1YH% zu^Rh32UCg>a-%?$cDA8L8{W{#XNxN-ip2ZO;F~e^GOYTy!uc6Zp{6bMR%=YyCp}ylRlClXX zwioA)U+fh*?s&yy=gXNdX#_0&(EIw_8?7H3E)^q>JKJ>rXx}(-A_HuP>mcqT>AT3q z7h-b9n^cNa!u@6pJQIzTe_@;b1Sxop-SP5e7puEBJ`9a-m+Rpoelp^3(XSQ}a!p~z zU~Y#Na(AalMT`)#uIAuU-9{4&C9O&*JoNeVrxCo5^$jI4+C?tfa18D;v3#@RnrTuS z;A=DtC{G2QKY|T=rGK=GbVUzKSI@nDKzWKcf*o#A5FcouL?4f2QD^Y%FnS8}?TU!x z&PSsWmw|g0nG(noF5{d7hOFq?@tM9JjtEJ-Y0_M zE^MQ>sx%~=3}*(Dyio82_vt#05g&Sa8|OvhZE5h5R0osh$WbmfHg3_d`OaqO`NhPr z6ng~425Tr)es?t^C-Y7=^V~7bv6WJMR%gUc3yGss!>$I)(Ta&HORUXsM-695bYI}G z#z?MRHJ%Tl3}f6X2=SdaFDHlKVumYCG?X+H5ux$+o4*xGpEHAc$Hg;~*29wf>hl+a zWjYs`%CZ`~r&gj^WS-__yZwCb-$%w1_ng*h)VG%=#J87*WMXtcei$Tbc;mkE*mFEN z`(rz@(L6FQvdxs^hwzUyMLMCk%a|Cr4J4dur?|#KcDsX0)_?0DETG$WgJ&`Wdj(#Z z==*VvY0X7WM}?(m>}G5=79{Qr-J>BorL6o_stkDQw9K;euwPZBr}I5O<jMcPg--TU7OHlKh**Z$sol=%;L~wleVYudEQ3@@cfVCKhDO?CbZ0F&Zvp1ch zOos^z>7}S*3k>^geSqO?`$HSE?tV`{k&1~J^0flJOWYZEx6Jx3>XyP9O=?Nqt!AE7(E|7SpA$Qj%)HUc- z@?u$^N#{$^x*wYGktWf>K2unx4uslje*PP#m5-t_Sz;G7|8C_=%#^U>0Rc=;McM~W z1W*FuPt>QMGj6UA-atkCjXW%zDS#ciO)_E5V?l;K2OAX%dcc4qq8 z-GEYCUk&)-HByQTGZ63Y-T#FpCAVw~+Ytgov_0-^z!kNyPCrUSA-v@$vno|5?^)Qs zLlS0Ur~0p5ysP~oa+npwJ6Y8+EsQ<{)tW(%$~{`#ZuJKx(AWpfSG}X|#kQPrKHxV( z=-@oUFr4DZpY@vqIrA$vJJkc-=| z+LRG~;HF)`ok)(?0MU!=t(}%(Jz{O`p0qx-NdlSnerivj8n`V*T!0^}R+7sJztDJnl}GJwsQkt5)U)dz!awrr8>pL|<%d?i=QoV>>h%sUuElO%RdB?iwjyCv z;gI=sfv+MJwf4bRj4ieti#X{1snM%<5lm7wywDZnJv4w;|clB+7rY zcomeuMU1GFw_MJN>D%JW$A;Y82hsyGw9=SAJq*!yZP{k?*xVZ6g0G6e>N02QW0o_Q!#VrN>vf3)kXM)8c)F=l z)bOV77abb1dTsQ9(D$fe#Pjwl)9e+g*yXXUj%#Gf+SsL=)`u@y`dC})FXS|ZhUrjl z3*AsFp^nWgN5NWyOQa#4m5`Y~?( z86i%MhHa^a?JX59Zqca)?YpTf-@o5-7f~+C+|u&fZ*XgV%WYhj2hy{B$_C#&!0(ZQ zM*dh%mZc$b?*#u;pI|-Q&mT+IMka|8k^W{T8{cy#x}S}cs%?+$l8Ras7JfXg6OR7( zQcm%>AQuLsC@NBP;l8DCO#FsXgm?i*z)bz=o(EJK;6HzGNF@8)0Et{*3YI#*1$?qF zCCL`8FQ)B<^4K`>`WSPOPTp-?sgYw|Hc;7>mM{z3qQViR82h=sK{)+zxAe^_kaGQ+ z{JH^cC7p(+?N8BZDalrk->+!0yx^Sv7)?b|I_(#xJfns;CSNm4B$ujxe#v|RZNY`sW{jBccbZuvUv6#YV7TtfR z%BjI{o|?dZUmG7|}2 zIAHho9491QHvH^MSE_vEVmJ;5meBM>?!C`?qhLpaWZik8f`%-hIu$StncpYSoUP0@ z9eEw5sifFQ=ER+rA2m49)bw*@mmIBEn^;O+PtWvw=s+CBli!aor4^bN=b}4u#4Ukx zcxB>R@y7ZZSSSoeR4UcwoP6( z8OK>_q1fKJEfjQjl(f(PLman$o|Hk?rVxAzuh8>-X^c+{wP)Xd%YbE#s$w}be7XV~ zwx-ews?xKO!Z8_^j}zy$te`w-Pv*%{Mv5;DcC{l4g0-RIe`I8H5ltxm_i@P@A1`cQ z*p76O>!k;5{k4I@gAW{i!a}Be4W+?2e_SMwWXy2%KHuhaIFY4O#Jm6|(+$$(y;)HT zMqG-aAYf1KK5)aK>ge4=rj~E{OoIjJq)!Aw<5|s-^%&)Qr$9D(+8zv96M#3l$Z|5q z-SO9-WYm^3h~KaoQTQ1z!^W4?5|o5rFrIHdWZ~3~ukRDlGBw3s-G2h4fcaTdpH;}iE5^y^<}ID8P9>*j1WpFIi%6V+ z3&4^G=HNi+e|}Pu^A^PJ%kyQK6#EPy8EDVG4Awf)x*3NvEkdz@@B)lX3(gmu78`e- z@2V7q9hL1v5f9PVH6PxKnAb?>#0Z~rQoEDfdP3VAIGsaS+vBKz5?p8?$r=9lO{nEd zB}m_lj|}t7lsEEV>`q6NCiUJ0Oc+pf21VUT?!a~O+|@z#tL>Vis%~E56;WO@Tzcz= zcJ{WaAOlW4^XZ%W(|f2^OWzdV2;_|0m!T8Iz6`ruP4Xvl1sP<*=c1;I&ID+`7um`S z^Ielv;=4VBd+jQ%{R|^1G2eD*tezVtJbDQRsWCnctWRBrMAoo$oov{~FY(1%prvGm zR+kQ0xH%r~QkKo-49;rU@NYdAW7+@}*64gb``$elD7FW-fFifAZ8I*nH9k}SQR}&6 z<|+>irptGnv>5e?TKstqjf!(t)zZE{PspL&mjLpRyLGjg z(91W!{X#MSTOl2AfgXt#@ zf7-7tGsvr4{o7+DQTgZ3#~?M!j*m7_CdHsTETFM!VPEOqx~<6&wa_~@AzT3$wkh>F z8C7fGQX5|kU0P*4byp9!^P0evUy$An(~iX!qK3Uf*Q%9uJPgk}_q=5?gnTH!9NnaKinazC9PF-YjYUIFRKmD|fi4brdkqT-iv-v7egmHS9ZHkd>C=F+chkvNP;& z*GJa2i-(TWCi{vV-kn4k&nJR*NYr_8}Dr z3tu`oO(Al0-4hWTQjmAAQ89$$SwB$hrca64NJKn(UKINPj4;nCvyW-TrOjN8bKup) zd3WKtV=DEw2R7Haq=JV-dJE_l~u*(TV4OHugk z{^*s0Jd3nZg&b*Zy<+5sCar5TmATdn>GP6#C$twF=0BQefD3Ow&2ZmvQ47bF)qC?% zML=S^M~{Qcql5=Iu>F=3ZvE+DI55p06X=Cfz+_c${2I`9G^Y3+DxBRLPZV%ePL9!j z$|YaUxsHXgd}vU0z0LK}c4L@eEVoLy5gw(pU+4cMfwm-8ocwbe|8xbj*dUhR0=@5p zP@j@*?Bk_YRw);ethG$s2RZpgl#|cBHUx|vDHvu8rR(5%k{7@bRhjzJKyv-cqIM{K z^E7BotqYX_YMsev^>+5L!Fg@@2|E(@`gWHstiAl;3G8hyR>Sg@7s4PS6-&NZ&-K|s z{B~;(&S56f`gXfIf89R~ykB@A(i<76^mAEAq}`j9_w7kfOx)&-iU@R#zax2*|6qZC zkr`skH9s&O@b75`O7XZ<^e{)d@Dt70fTb@5k7eAcx*O+9wC5aSq>?K|;ZgaV42^ta zBpYRq;Sx!qSrxm=V`w+3*p#`6+U1boS)k<&&L&h2JH}8$n%Pu-AD7WKr=A6kX)+1k zJ&1e$1jt!US)|HtUtcZjdqVuf1*B=u0O2D8W@zAxfalKc0!g%Qr~ue(>@~Mts!k9U z3b=K@P(zUlORpp>wN;&KDAGj|VHyvV;N6uGtOg3Z>)C6P@4`Z=w|Wi9azI!Qn9tW% zD?l9X4?sR)zAa$dgwoepaBj6LKMW`1RiGVAJzy*-`W!{GlXE^v`&*X6z}T5nuw%{w%&3L)LOF?=w-KUqy+i!L;3}3rJT3 z9G8gknzi;@t?12`m{Bu5!FL3e;fJqIiw~W$(WC5*J5hxo@TZ>H-N3Xy_hpl`?)Z_N z#O^74W4e0!CCdUq8mQ?P%f%YcYX{v%0vd%KlnIf@v{FsXD&N0o3c1+ZAhCPYaQw9^ z=ZOtmpDbTpWk?ueWjvKP;oWd$713vGCHb28z0o-5zV>@mZ^hN6lMKV)=-_N@Ri%k% zG`{JZBYi-oMwX4TixsCz@93>Cj>R`f(0x!I9W1oYn^*F;3D?DsYE+Q!WD+7ibIXwwZboO}{9P9(l$@l*OB1cr&dob!GYL*W;>enO4;XUF0Y zAQO&mCa*G>TBA_O51xE6(Dq>7yjjKVP}-$zxUP6wQzG&Sq^Wk}?gf#L8<$l0dTtft zHEd@-N}tf3UuH?L3sF72ZUB_!h@c*wpwrl2uk)or7kzAi;GM61C2_M{=L3`^-MU?> z=%RL3lhY8D?7(|3c}{Y@P2;)jIa4o;R7auYtdmtY&>UCq0{Xq>ha9#Hop0{rrMlOn z#IxFW872uGKK-WfrDk(Jq4Arz9rk3ImF?<+x&%?Z$1j}JG;TtZCp_54ttG!dFu~Vb z@0{N|Qut(m;>7Cb5Qrn8hvryqe)HZVZpRwIz;U0PRn$$O#*s1gXBWK`H}7`^)=?8m zo3rI=@}Y8Mn{=&n1|pnBTkmy##~rLzp(e(1NP}G_#wg+iNry4tC*T*$ol9 z8ofKqDT7rTO`);C(od7#P%Vn)u+I{ZaD;VTJF>>q+uB1>-n7dEQs)1CoS}B z%eMF6C3&anKgqp>ur~)pmyD1vp+}8$McS*=^9p+}n0hl>wvAoEVSdml-s_4JJ^fIf ze@~bA;<$7?o|mx5@g4^!3sOJ-nB&W;*hq@t`8z7cuI1dZXq8-6(=Se$00fv37A{Jn zJ#Y98_zOpHa&Fyxvh|M}R~}wzKGIx5h&6{hW2K)T`%dpkGIgja>rtp1ckXMzwCw>k z!o3D)jP_*)PO6zv8QCFkPMZ33!M_uzPC*9K`x5D!+ZYe`kf01jJj~wuEk^WdmVgS* zhQY33*p4m3oq4+o(V@{%aM|)!vTytrSTQImXWXjwoT-bX+%4*`n5nL_YNaY7!zCk7 z=`J39-F1zss_cXDPC}w2UpQ-9{UHD7lCoCE7>XlLdV(J zs_E6)UW2A4%)M+?S+I8nL^n?rwv;0i>sMD{2 z^Zxyn5Rqg4zi}4{-5>s6B`p4Z9|FY1|M#Jg{~WUGJ=_jJ;N&;HVk(%d4=I3#?e{kw z>5cK%mp(^T@OsygS~-u7kVeDy|K^I1OlHCNuM^@_Cik9Xkj$mCume?D9H!MgM+aIE zV%I|=#Au5GqR307L2ece*=(0aPXUUk_=p#9U?7x+5{;Lg+%u@)hk4Gj z!>Aiygs@YH1LGePa6+wl=u|_Pc$B*-)ge;waVUG73qJ`-KbVSJe@F$8p(k}DHbHkm6=m!Bqp&d-oi#qzAlwEb?2aG5UXi%6J+tst4de!()mgoQe*WcSb*Zp1Z zW!=xc2O2w)OdGbpdmF#~`2TzsaK%+Nmkb}>qZ21YDvALad@;$~dypq3N|GNrpx`=eZHNv-^_07BtdoJxbxY|#1>Q|fz{toLVCr=KeV^7aC$OTJSw zamu3LE^T17)pQKLl;SM+IMpG-1`9L-ZHlILKK-*0tw-I;AgeAw@`esgow@c+wq*&u zySua#91UBmB8ZJZzt3XE0u;0M6&j$kZpb4Ezl*tU^hx17^xar|7uKXY@+o-|9lJr_(nGwW?V=m|tS){iAJ1YDQ)GJ*2D6gU%_y5JIRx zC9|YVn@cq9CY22U>ixrx=;36$;KePVxnEop4_QBNT?F09edE?q9y_3;z+y zq`3U8bb-Wn>YAJ6TEoGbB2Wbp_YVP`z%nk`t24``m>~PU^z!mg9+6Dl{+-ep- zb*_2?dg3=-00Zj3EUFv(+1OVoY^!I$H6bD*ie5zr6-BiZGyHoE%6DGC?`khaHiR9R zSb@gw7)rgHMA8qJqx9`0bMgr)EQZdJ7_va`hbwKRD$B9`5hz4M2poE$e-hy(!f&lWuB3FlxFI>%s zUb>Ww3Oy?{loOT#pN#^JT5J^v=r@ZV4!-_hz*?T!ZQS6ngkzyMfS>sb{=wQQh)9JGp*0^cC>fU@K#6?Cu}~okNMN>(nZTh>l^*1MRHMu#y6+;_C+iD)L^RT9hX+HWZ9iWy0`Gd`)K*HG zpR5M{{qu3)FW>j*;Z`&_8~fZYVBlV#@|Z_1^Ve=TfJXL;rF*fKqk+v5~cw{q4aZ~0=56V3qM1#hls{$$ZG2$%y+K+Ntr%+VKzpls2mNL;5R-gk%?3Q z2rC#w6axUl6aTpT{~Ks1V$S)$ef)pNA09c)Djz~Ga0~B_5Ueu5Uljn^V&T!wJX(~p zRzCN+{jo=h#=QV2U_SA$Xu#QgK#V$$TX#*Ba?1*ivMg)#| zRz4~7_dCLA$^Zq9xIgAzT>rI4ME(LWes|w7Q31r>%)hqB+)Ebe&l*$lFxnn)+gb{t zdmE#N$jx8kz;+2%)QUX&y&Mg=<9oR}dHj9=JJUb9jsl5(?NI{qoH#*sWnjK-zE~97 zxdHn8t>XHf2LarRtw49u$?d4+xV@XX9x7fB7~FvEzXIseO8{;pV%Qu2M9CaC_|PV7 zd!T&Z)~`#cu&1|+SLw;!@3cWHm&_++C?lQJx!9e`7B1_%xxmdYy?w#RG??WLa5(Nq zwzb$-V7f{>3XK2Q77)$y2BB1xUS7j6psu>FRwC)Ne;HO*@X{B>^*Zu`5tpsMnT|(5 zE8v2&7JzVIg9HGE@c+tyzF2z!T-v{a1DjCu1&k6GP}_Q@0g=b=5Fr0*23!{xYyk88 z$JK3DHlE@K2KMNx?^-o+F!ralcYVOWwtyN5$LWq&5rgOipF7;&E}Wn<2|T?4j9Ks1 zQ>oE5pht5LFhwucrWIH0_n^^xxceJGbh)phbRv5AlfZiYu3bRlKOWCW>pVtDIeK`B zAFwRj$fI$(X`ja!m_B|GHOv=)u;DxU$F^Z52POK1IY*Z-^J|!m&rc>T9m*S z+p2BQ0qVkR;(PPcY&oF@0J2otsJ!W-;{guaGLv>ne%Hcv;fN#|N#5wVR@`5qlL_1S z$vCpMHSC0*uaT+rMx^wi6y*g0je<&QtDewF4+8Ga?_jALvODfqZ3;i!9x61Oh<`$X z?oGd1T&Z#*}-KsHx*>-EJq>u^9?v zSCKzvApTuskj)mr8r}{3C0jA!R*<<#-2;NGW!zfD^wT`ZPWm0cZgb8_j2aP$It6@} z1@m%6Dvl;7=OEIhx$LD6*ZQhY=sxvm{|!v=@r~ZFc!$($oWxZxt+;md%!^cT+lR`T zO5zND%e4u(MG8Lq`z&*-JwcSL-M+tEY#(>)hzL3&@{*oSh3!7MYx{>6#DCxgYz#MG zI`NCxjn^`HfC#!PsC-UNSPqE51wav5%{L%?q?+adn?$KgwwYCpIz>MO;L>$OiXE_- zmWnbzc~3P|vmyZ841keOvP-NL1J!9U-{pT?=LKM9oM75Ya)=ueIyavB4Ld~M3tX?T z2_>}zQGY%%5$y%$MmfS8IK3KS00Y3xeDAPjYqvJRzzh-HPwddWi&EwNcSe+Zdq_`B zKPZSOi`^fzbc%a$aOAW~&0?b70WC_Yv2!~BH#$mV9yZW3X;0~}*rfEz z^rVCxj7^ywfsI@r%2PC7oth6m>bi=LcIOSH0GQQQUp7sVC0IwBFD^cMc<(M+rW2OBMCT;qYDqj8w!GWDB5bwyxb`}=s4cG zM*&#w3<<9XPXXGOxSI~_?sC3?tG~zm!bpgg+Cd=YAlKE`ZjMdSlq=WO*Q#>=%R-K* z&T8qLu!kX*N0>g4YppL8nG1MuA7wK_fer`L5Wq90E_nqs0*$kGV=}Wvzh)}NywdZi z7S^8Sl55LF-|BZptMM~hx8BS9SEJZ4=N3QPm^^&%oD85MPX;D#oSXn8rXy_9gE2TT z2Ur2tOL@7ROMpMz|7D7Ji#&RcV8C{)1#r!YeMgaV;PY9`Q5>KiahBAQ*~_n|j^ z_1Dk_z=+iI4R8R?Ai^OlDwK7GXAiquzdj$zpWZj(t!+x3fUT!?1hVX5-GVA9eSkbSJb% zMNLS|oA{#`mg%Yve$%%u&nqUL8o@+6&{e-+t>{Z~Z&JOi%%< z2k0Syr6>An{}eTk0PeR0^d@3ps0x|m_2;(H>N~!@zb95t9v3QOO$=F^h;j`~bT@ZK z_DTU{Ts0MQNa9DonHWfFb-{+gNP)#Fe#W9rd4 z)Yptb$#G1O69)WQb9-OY4~Mf4vw-kMh0PW{m-!1%*ju)zr^PW(yy!*^bAaz4ljLqA zKW}%6C^npFwB{d^6M4rkdsF_pQ|vDwV{xVhr;(XO6~=u#rQ853>kN4HB+xerI92vr zi@d0jWGH|Qcb&0YiuB7pD+9p`HtXD#I0yX{YYjgP)ol!||y@9w0IrSxXVH{P&s!2Xj*Ji2pT)X@nk7Hmz< zYYAr{Txd<9w)pw}jFbv3#{vb>qvAhsb7`5NcAht+tCP2n%APVHj`b(cJskg;hXbOO z?HCdeKq)-2qCDyeS)^Nji^Tv30zshIV`SH45f6XcbSGlioz86jY_{R~TzyviW~@Ev zGzRQ=lD2MQf9~0RTm=xqh9EuDBP`ytJWHh3R`?<10^T;*f&31fq z)X$MB5(3<0+&rYDdgEkR()wq!+}NC$SeoeTiEB7s^KhKUCfPtv(;et2T@ct<(=BWP1tt7xgTw-bl@~|U5!I6 z4pBMgRXgA@0>3dI+L2f&X{E#KC+#U!&nKenk;>5asTk*F61*>^%{Rzd=Ls^ml#vY1`077gaDfT-4(PiB^u@La{ZbkVT5A?(XHI- zuX^o+j5r-Y@-%j{QR7dafi|QYmWyf^Fkte{mA2AZps&$EVrEZ0m=Vmvh=FZ3>R8}( zmm(&1*w$1mwxjbC2?n6PN7ec!If5LccBz#+^iSiNI}p*-xy)V@uDp;wD-eH0%*D0h zjjC37bl|DhvJ=U>EIj54&*n4350AK`$M_*(^z&;>n|avzk6_EA3$@p6P!_2<(Rtb`(X%o6@$xE1Z-* z6~z=}P|za}4wtN;$62+XLaSjp$eFq?W4>0trVo+Rs&PkoR+10LOUV_Ep9hrb##f~# zFGI2qE_twxvy5Y%t@+j`J=BlNvPFYFoBipdr^MPGW!!m9)T7f5$@QB1zH~J0g$oGJ zv<6zU-;(>7-YDI27~A^d0023?yH{Vi*bQ5&a(BP0J8g5#ZuXeEIE52m6@9=is4Z#u zo8tSOMY7}u6uBSyMP3F@+5de+6dzGlm+k1bO>dNGE2mum|{jWcy zBP;sv1dm=>{E`TC(TH;A2%WwMNFe6;VNqCH9Iku!J(!*K45pJ|!hJD~sLd3ex z^MbOZ_Ot5TDL%0~Pls+z`k*(Q9e8g2JvMs^caJs%JQ_d+I3GA~0aah?&}?#QuT@11 z^s-$09}&NIIkCwC^bDk?;zI~(gmmwhS>O}-?Y#bkBErQ=YO zuRELY5RTSl<@DegF=z`QrG(0^v>jg9wWH9|dJU8Rf%p{9nBT;;9$Y8n0FTs&^1l#a z$H~e)M(ys(0ERtcd5lNTG1OehNzU#vJ1e(`69ffH4h%bCmFM!i-SWB2t7h3>q+wWc zRn(S|Y$7zd6{p{X;f0mNh4B>N_86SlaC%y?^{F`VRd(cFGvwL4mi+AA-$)V*CEUfm zRdrRW?Mqh@2j=wz10%=0R*)=%KTwA(gaUjCP4e6vvh}mW-8HRaTzPq`w>qoVVg zAx){=x4RvX)6H6&aruwiv-g`g+7Dx9*bh#4ey`q<>ir9FXb~@<+CNFZKTMqvd%b7O zuannGhZM;6lglB8%ATpt8H_~TB=-4 z3Y)BELPN3onRL*1Qo~QCN}Ir8nIHsNGNb1MBR2ZNW&uV7yCs zV^IEqpCV9f!e`h2Qcb4}==@}@-%&ku`A;Z=Z8%iNULdPCb(p9^c(Wm5l#QE#xrR%`X+H*gQn@y!M=V z6d@K#WlsnKnUA5c?=7F70kHMf<=L zE#AX{dDr$0RW}mhl^e>I0rm{1H#gqNLocH-!phgPCbl%Y^4UFa?3He$+_RpnU2L#7Zol-x)NYSE=sQG45f>r6O#8khyIa1J&ZZ7HzIsK z0AKMVu_U;lQ^x}$!MoLFFwOU-RWMF5mZPqp!E2h*6~(9`izVfq>X9pf=;sj`Vp7@m zI$rF_ir{Dxm(;vjzsmXij1gfBD?%494tHy*U(8Zv6`5;HIm?`T8y@jl`g*zSb@BiN zTR2%$^A^bA;G}5=)NGmB&7kPCq~bfbd&&F+Ncar1lH{p5xEm@W(vl_DnCBzu;thy> zekD^kdcTdQHJnlAk(q`6abZWTUnYmSPZr=wj3gf#Pnas*RaWja?$N2^&<0EGY9pJV z5mT{{j;tkBrkxpnEOBn0Ox0BDq; z@Wq=p;m=QNowYOK$(467nhgEce4xI*9m`312@*Z;C^5?m+kEY0bW1ktVdx8nRE%F8 zUZLCJ+c|rz<3nfq#=ec)Mf$t#D3Rq{C2d)kawsR8{18&&*t@rLo*srMJ%)5IU z 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ⅅ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
Fv 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≥}>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 177/347] 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 178/347] 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 179/347] 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 180/347] 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 181/347] 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 182/347] 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 183/347] 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 184/347] 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 185/347] 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 186/347] 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 187/347] 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 188/347] 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 189/347] 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 190/347] 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 191/347] 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 192/347] 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 193/347] 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 194/347] 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 195/347] 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 196/347] 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 197/347] 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 198/347] 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 199/347] 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 200/347] 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 201/347] 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 202/347] 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 203/347] 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 204/347] 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 205/347] 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 206/347] 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 207/347] 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 208/347] 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 209/347] 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 210/347] 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 211/347] 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 212/347] 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 213/347] 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 214/347] 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 215/347] 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 216/347] 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 217/347] 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 218/347] 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 219/347] 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 220/347] 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 221/347] 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 222/347] 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 223/347] 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 224/347] 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 225/347] 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 226/347] 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 227/347] 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 228/347] 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 229/347] 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 230/347] 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 231/347] 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 232/347] 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 233/347] 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 234/347] 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 235/347] 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 236/347] 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 237/347] 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 238/347] 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 239/347] 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 240/347] 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 241/347] 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 242/347] 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 243/347] 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 244/347] 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 245/347] 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 246/347] 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 247/347] 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 248/347] 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 249/347] 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 250/347] 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 251/347] 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 252/347] 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 253/347] 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 254/347] 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 255/347] 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 256/347] 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 257/347] 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 258/347] 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'" } From 867055d88d67f7cf817904a0922c945be3cc5185 Mon Sep 17 00:00:00 2001 From: omerr-cycode Date: Wed, 11 Mar 2026 17:43:41 +0200 Subject: [PATCH 259/347] CM-60869 SCA add restored files cleanup mechanism (#409) --- .../sca/base_restore_dependencies.py | 10 ++ .../sca/maven/restore_maven_dependencies.py | 27 ++-- tests/cli/files_collector/sca/__init__.py | 0 tests/cli/files_collector/sca/go/__init__.py | 0 .../sca/go/test_restore_go_dependencies.py | 90 +++++++++++ .../cli/files_collector/sca/maven/__init__.py | 0 .../maven/test_restore_gradle_dependencies.py | 120 ++++++++++++++ .../maven/test_restore_maven_dependencies.py | 124 +++++++++++++++ .../sca/npm/test_restore_npm_dependencies.py | 39 +++++ .../sca/npm/test_restore_pnpm_dependencies.py | 44 +++++- .../sca/npm/test_restore_yarn_dependencies.py | 44 +++++- .../cli/files_collector/sca/nuget/__init__.py | 0 .../nuget/test_restore_nuget_dependencies.py | 89 +++++++++++ .../php/test_restore_composer_dependencies.py | 51 +++++- .../test_restore_pipenv_dependencies.py | 47 +++++- .../test_restore_poetry_dependencies.py | 52 +++++- .../cli/files_collector/sca/ruby/__init__.py | 0 .../ruby/test_restore_ruby_dependencies.py | 89 +++++++++++ tests/cli/files_collector/sca/sbt/__init__.py | 0 .../sca/sbt/test_restore_sbt_dependencies.py | 89 +++++++++++ .../sca/test_base_restore_dependencies.py | 148 ++++++++++++++++++ 21 files changed, 1046 insertions(+), 17 deletions(-) create mode 100644 tests/cli/files_collector/sca/__init__.py create mode 100644 tests/cli/files_collector/sca/go/__init__.py create mode 100644 tests/cli/files_collector/sca/go/test_restore_go_dependencies.py create mode 100644 tests/cli/files_collector/sca/maven/__init__.py create mode 100644 tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py create mode 100644 tests/cli/files_collector/sca/maven/test_restore_maven_dependencies.py create mode 100644 tests/cli/files_collector/sca/nuget/__init__.py create mode 100644 tests/cli/files_collector/sca/nuget/test_restore_nuget_dependencies.py create mode 100644 tests/cli/files_collector/sca/ruby/__init__.py create mode 100644 tests/cli/files_collector/sca/ruby/test_restore_ruby_dependencies.py create mode 100644 tests/cli/files_collector/sca/sbt/__init__.py create mode 100644 tests/cli/files_collector/sca/sbt/test_restore_sbt_dependencies.py create mode 100644 tests/cli/files_collector/sca/test_base_restore_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 ac391727..06431f72 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -92,7 +92,9 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: ) if output is None: # one of the commands failed return None + file_was_generated = True else: + file_was_generated = False logger.debug( 'Lock file already exists, skipping restore commands, %s', {'restore_file_path': restore_file_path}, @@ -107,6 +109,14 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: 'content_empty': not restore_file_content, }, ) + + if file_was_generated: + try: + Path(restore_file_path).unlink(missing_ok=True) + logger.debug('Cleaned up generated restore file, %s', {'restore_file_path': restore_file_path}) + except Exception as e: + logger.debug('Failed to clean up generated restore file', exc_info=e) + return Document(relative_restore_file_path, restore_file_content, self.is_git_diff) def get_manifest_dir(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 34499bdf..740ccca9 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,5 @@ from os import path +from pathlib import Path from typing import Optional import typer @@ -9,7 +10,10 @@ execute_commands, ) 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, join_paths +from cycode.logger import get_logger + +logger = get_logger('Maven Restore Dependencies') BUILD_MAVEN_FILE_NAME = 'pom.xml' MAVEN_CYCLONE_DEP_TREE_FILE_NAME = 'bom.json' @@ -42,15 +46,8 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: if document.content is None: 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 + # super() reads the content and cleans up any generated file; no re-read needed + return super().try_restore_dependencies(document) def restore_from_secondary_command(self, document: Document, manifest_file_path: str) -> Optional[Document]: restore_content = execute_commands( @@ -62,11 +59,17 @@ def restore_from_secondary_command(self, document: Document, manifest_file_path: return None restore_file_path = build_dep_tree_path(document.absolute_path, MAVEN_DEP_TREE_FILE_NAME) + content = get_file_content(restore_file_path) + + try: + Path(restore_file_path).unlink(missing_ok=True) + except Exception as e: + logger.debug('Failed to clean up generated maven dep tree file', exc_info=e) + return Document( path=build_dep_tree_path(document.path, MAVEN_DEP_TREE_FILE_NAME), - content=get_file_content(restore_file_path), + content=content, is_git_diff_format=self.is_git_diff, - absolute_path=restore_file_path, ) def create_secondary_restore_commands(self, manifest_file_path: str) -> list[list[str]]: diff --git a/tests/cli/files_collector/sca/__init__.py b/tests/cli/files_collector/sca/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/go/__init__.py b/tests/cli/files_collector/sca/go/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/go/test_restore_go_dependencies.py b/tests/cli/files_collector/sca/go/test_restore_go_dependencies.py new file mode 100644 index 00000000..633d24e8 --- /dev/null +++ b/tests/cli/files_collector/sca/go/test_restore_go_dependencies.py @@ -0,0 +1,90 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.go.restore_go_dependencies import ( + GO_RESTORE_FILE_NAME, + RestoreGoDependencies, +) +from cycode.cli.models import Document + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +@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_go(mock_ctx: typer.Context) -> RestoreGoDependencies: + return RestoreGoDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_go_mod_matches(self, restore_go: RestoreGoDependencies) -> None: + doc = Document('go.mod', 'module example.com/mymod\ngo 1.21\n') + assert restore_go.is_project(doc) is True + + def test_go_sum_matches(self, restore_go: RestoreGoDependencies) -> None: + doc = Document('go.sum', 'github.com/pkg/errors v0.9.1 h1:...\n') + assert restore_go.is_project(doc) is True + + def test_go_in_subdir_matches(self, restore_go: RestoreGoDependencies) -> None: + doc = Document('myapp/go.mod', 'module example.com/mymod\n') + assert restore_go.is_project(doc) is True + + def test_pom_xml_does_not_match(self, restore_go: RestoreGoDependencies) -> None: + doc = Document('pom.xml', '') + assert restore_go.is_project(doc) is False + + +class TestCleanup: + def test_generated_output_file_is_deleted_after_restore( + self, restore_go: RestoreGoDependencies, tmp_path: Path + ) -> None: + # Go handler requires both go.mod and go.sum to be present + (tmp_path / 'go.mod').write_text('module example.com/test\ngo 1.21\n') + (tmp_path / 'go.sum').write_text('github.com/pkg/errors v0.9.1 h1:abc\n') + doc = Document( + str(tmp_path / 'go.mod'), + 'module example.com/test\ngo 1.21\n', + absolute_path=str(tmp_path / 'go.mod'), + ) + output_path = tmp_path / GO_RESTORE_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + # Go uses create_output_file_manually=True; output_file_path is provided + target = output_file_path or str(output_path) + Path(target).write_text('example.com/test github.com/pkg/errors@v0.9.1\n') + return 'graph output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_go.try_restore_dependencies(doc) + + assert result is not None + assert not output_path.exists(), f'{GO_RESTORE_FILE_NAME} must be deleted after restore' + + def test_missing_go_sum_returns_none(self, restore_go: RestoreGoDependencies, tmp_path: Path) -> None: + (tmp_path / 'go.mod').write_text('module example.com/test\ngo 1.21\n') + # go.sum intentionally absent + doc = Document( + str(tmp_path / 'go.mod'), + 'module example.com/test\ngo 1.21\n', + absolute_path=str(tmp_path / 'go.mod'), + ) + + result = restore_go.try_restore_dependencies(doc) + + assert result is None diff --git a/tests/cli/files_collector/sca/maven/__init__.py b/tests/cli/files_collector/sca/maven/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py b/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py new file mode 100644 index 00000000..72ca8a7d --- /dev/null +++ b/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py @@ -0,0 +1,120 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.maven.restore_gradle_dependencies import ( + BUILD_GRADLE_DEP_TREE_FILE_NAME, + BUILD_GRADLE_FILE_NAME, + BUILD_GRADLE_KTS_FILE_NAME, + RestoreGradleDependencies, +) +from cycode.cli.models import Document + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False, 'gradle_all_sub_projects': False} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_gradle(mock_ctx: typer.Context) -> RestoreGradleDependencies: + return RestoreGradleDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_build_gradle_matches(self, restore_gradle: RestoreGradleDependencies) -> None: + doc = Document('build.gradle', 'apply plugin: "java"\n') + assert restore_gradle.is_project(doc) is True + + def test_build_gradle_kts_matches(self, restore_gradle: RestoreGradleDependencies) -> None: + doc = Document('build.gradle.kts', 'plugins { java }\n') + assert restore_gradle.is_project(doc) is True + + def test_pom_xml_does_not_match(self, restore_gradle: RestoreGradleDependencies) -> None: + doc = Document('pom.xml', '') + assert restore_gradle.is_project(doc) is False + + def test_settings_gradle_does_not_match(self, restore_gradle: RestoreGradleDependencies) -> None: + doc = Document('settings.gradle', 'rootProject.name = "test"') + assert restore_gradle.is_project(doc) is False + + +class TestCleanup: + def test_generated_dep_tree_file_is_deleted_after_restore( + self, restore_gradle: RestoreGradleDependencies, tmp_path: Path + ) -> None: + (tmp_path / BUILD_GRADLE_FILE_NAME).write_text('apply plugin: "java"\n') + doc = Document( + str(tmp_path / BUILD_GRADLE_FILE_NAME), + 'apply plugin: "java"\n', + absolute_path=str(tmp_path / BUILD_GRADLE_FILE_NAME), + ) + output_path = tmp_path / BUILD_GRADLE_DEP_TREE_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + # Gradle uses create_output_file_manually=True; output_file_path is provided + target = output_file_path or str(output_path) + Path(target).write_text('compileClasspath - Compile classpath:\n\\--- org.example:lib:1.0\n') + return 'dep tree output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_gradle.try_restore_dependencies(doc) + + assert result is not None + assert not output_path.exists(), f'{BUILD_GRADLE_DEP_TREE_FILE_NAME} must be deleted after restore' + + def test_preexisting_dep_tree_file_is_not_deleted( + self, restore_gradle: RestoreGradleDependencies, tmp_path: Path + ) -> None: + dep_tree_content = 'compileClasspath - Compile classpath:\n\\--- org.example:lib:1.0\n' + (tmp_path / BUILD_GRADLE_FILE_NAME).write_text('apply plugin: "java"\n') + output_path = tmp_path / BUILD_GRADLE_DEP_TREE_FILE_NAME + output_path.write_text(dep_tree_content) + doc = Document( + str(tmp_path / BUILD_GRADLE_FILE_NAME), + 'apply plugin: "java"\n', + absolute_path=str(tmp_path / BUILD_GRADLE_FILE_NAME), + ) + + result = restore_gradle.try_restore_dependencies(doc) + + assert result is not None + assert output_path.exists(), f'Pre-existing {BUILD_GRADLE_DEP_TREE_FILE_NAME} must not be deleted' + + def test_kts_build_file_also_cleaned_up(self, restore_gradle: RestoreGradleDependencies, tmp_path: Path) -> None: + (tmp_path / BUILD_GRADLE_KTS_FILE_NAME).write_text('plugins { java }\n') + doc = Document( + str(tmp_path / BUILD_GRADLE_KTS_FILE_NAME), + 'plugins { java }\n', + absolute_path=str(tmp_path / BUILD_GRADLE_KTS_FILE_NAME), + ) + output_path = tmp_path / BUILD_GRADLE_DEP_TREE_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + target = output_file_path or str(output_path) + Path(target).write_text('compileClasspath\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_gradle.try_restore_dependencies(doc) + + assert result is not None + assert not output_path.exists(), f'{BUILD_GRADLE_DEP_TREE_FILE_NAME} must be deleted after restore' diff --git a/tests/cli/files_collector/sca/maven/test_restore_maven_dependencies.py b/tests/cli/files_collector/sca/maven/test_restore_maven_dependencies.py new file mode 100644 index 00000000..f365bd92 --- /dev/null +++ b/tests/cli/files_collector/sca/maven/test_restore_maven_dependencies.py @@ -0,0 +1,124 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.maven.restore_maven_dependencies import ( + BUILD_MAVEN_FILE_NAME, + MAVEN_CYCLONE_DEP_TREE_FILE_NAME, + MAVEN_DEP_TREE_FILE_NAME, + RestoreMavenDependencies, +) +from cycode.cli.models import Document + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' +_MAVEN_MODULE = 'cycode.cli.files_collector.sca.maven.restore_maven_dependencies' + + +@pytest.fixture +def mock_ctx(tmp_path: Path) -> typer.Context: + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False, 'maven_settings_file': None} + ctx.params = {'path': str(tmp_path)} + return ctx + + +@pytest.fixture +def restore_maven(mock_ctx: typer.Context) -> RestoreMavenDependencies: + return RestoreMavenDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_pom_xml_matches(self, restore_maven: RestoreMavenDependencies) -> None: + doc = Document('pom.xml', '') + assert restore_maven.is_project(doc) is True + + def test_pom_xml_in_subdir_matches(self, restore_maven: RestoreMavenDependencies) -> None: + doc = Document('mymodule/pom.xml', '') + assert restore_maven.is_project(doc) is True + + def test_build_gradle_does_not_match(self, restore_maven: RestoreMavenDependencies) -> None: + doc = Document('build.gradle', '') + assert restore_maven.is_project(doc) is False + + +class TestCleanup: + def test_generated_bom_is_deleted_after_primary_restore( + self, restore_maven: RestoreMavenDependencies, tmp_path: Path + ) -> None: + """Primary path: super().try_restore_dependencies() generates target/bom.json and cleans it up.""" + pom_content = '4.0.0' + (tmp_path / BUILD_MAVEN_FILE_NAME).write_text(pom_content) + target_dir = tmp_path / 'target' + target_dir.mkdir() + bom_path = target_dir / MAVEN_CYCLONE_DEP_TREE_FILE_NAME + doc = Document( + str(tmp_path / BUILD_MAVEN_FILE_NAME), + pom_content, + absolute_path=str(tmp_path / BUILD_MAVEN_FILE_NAME), + ) + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + bom_path.write_text('{"bomFormat": "CycloneDX", "components": []}') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_maven.try_restore_dependencies(doc) + + assert result is not None + assert result.content is not None, 'Document content must be populated even after file deletion' + assert not bom_path.exists(), f'target/{MAVEN_CYCLONE_DEP_TREE_FILE_NAME} must be deleted after restore' + + def test_generated_dep_tree_is_deleted_after_secondary_restore( + self, restore_maven: RestoreMavenDependencies, tmp_path: Path + ) -> None: + """Secondary path (content=None): mvn dependency:tree generates bcde.mvndeps and it must be cleaned up.""" + (tmp_path / BUILD_MAVEN_FILE_NAME).write_text('') + dep_tree_path = tmp_path / MAVEN_DEP_TREE_FILE_NAME + # content=None triggers the secondary command path + doc = Document( + str(tmp_path / BUILD_MAVEN_FILE_NAME), + None, + absolute_path=str(tmp_path / BUILD_MAVEN_FILE_NAME), + ) + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + dep_tree_path.write_text('[INFO] com.example:my-app:jar:1.0.0\n') + return '[INFO] BUILD SUCCESS' + + with patch(f'{_MAVEN_MODULE}.execute_commands', side_effect=side_effect): + result = restore_maven.try_restore_dependencies(doc) + + assert result is not None + assert result.content is not None + assert not dep_tree_path.exists(), f'{MAVEN_DEP_TREE_FILE_NAME} must be deleted after restore' + + def test_preexisting_bom_is_not_deleted(self, restore_maven: RestoreMavenDependencies, tmp_path: Path) -> None: + pom_content = '4.0.0' + (tmp_path / BUILD_MAVEN_FILE_NAME).write_text(pom_content) + target_dir = tmp_path / 'target' + target_dir.mkdir() + bom_path = target_dir / MAVEN_CYCLONE_DEP_TREE_FILE_NAME + bom_path.write_text('{"bomFormat": "CycloneDX", "components": [{"name": "requests"}]}') + doc = Document( + str(tmp_path / BUILD_MAVEN_FILE_NAME), + pom_content, + absolute_path=str(tmp_path / BUILD_MAVEN_FILE_NAME), + ) + + result = restore_maven.try_restore_dependencies(doc) + + assert result is not None + assert bom_path.exists(), f'Pre-existing target/{MAVEN_CYCLONE_DEP_TREE_FILE_NAME} must not be deleted' 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 aa145de3..c418b659 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 @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Optional from unittest.mock import MagicMock, patch import pytest @@ -99,6 +100,44 @@ def test_get_lock_file_names_contains_only_npm_lock(self, restore_npm: RestoreNp assert restore_npm.get_lock_file_names() == [NPM_LOCK_FILE_NAME] +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + 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')) + lock_path = tmp_path / NPM_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('{"lockfileVersion": 3}') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_npm.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{NPM_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_npm: RestoreNpmDependencies, tmp_path: Path) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + lock_path = tmp_path / NPM_LOCK_FILE_NAME + lock_path.write_text('{"lockfileVersion": 3, "packages": {}}') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + + result = restore_npm.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {NPM_LOCK_FILE_NAME} must not be deleted' + + class TestPrepareManifestFilePath: def test_strips_package_json_filename(self, restore_npm: RestoreNpmDependencies) -> None: path = str(Path('/path/to/package.json')) 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 index 312cce83..88502578 100644 --- a/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py +++ b/tests/cli/files_collector/sca/npm/test_restore_pnpm_dependencies.py @@ -1,5 +1,6 @@ from pathlib import Path -from unittest.mock import MagicMock +from typing import Optional +from unittest.mock import MagicMock, patch import pytest import typer @@ -89,3 +90,44 @@ def test_get_lock_file_name(self, restore_pnpm: RestorePnpmDependencies) -> None 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']] + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path + ) -> None: + # pnpm: no pre-existing pnpm-lock.yaml but package.json indicates pnpm + content = '{"name": "test", "packageManager": "pnpm@8.6.2"}' + (tmp_path / 'package.json').write_text(content) + doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json')) + lock_path = tmp_path / PNPM_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('lockfileVersion: 5.4\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_pnpm.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{PNPM_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_pnpm: RestorePnpmDependencies, tmp_path: Path) -> None: + lock_content = 'lockfileVersion: 5.4\n\npackages:\n /pkg@1.0.0:\n resolution: {}\n' + (tmp_path / 'package.json').write_text('{"name": "test"}') + lock_path = tmp_path / PNPM_LOCK_FILE_NAME + lock_path.write_text(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 lock_path.exists(), f'Pre-existing {PNPM_LOCK_FILE_NAME} must not be deleted' 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 index 13e321c9..88175031 100644 --- a/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py +++ b/tests/cli/files_collector/sca/npm/test_restore_yarn_dependencies.py @@ -1,5 +1,6 @@ from pathlib import Path -from unittest.mock import MagicMock +from typing import Optional +from unittest.mock import MagicMock, patch import pytest import typer @@ -89,3 +90,44 @@ def test_get_lock_file_name(self, restore_yarn: RestoreYarnDependencies) -> None 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']] + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_yarn: RestoreYarnDependencies, tmp_path: Path + ) -> None: + # Yarn: no pre-existing yarn.lock but package.json indicates yarn + content = '{"name": "test", "packageManager": "yarn@4.0.2"}' + (tmp_path / 'package.json').write_text(content) + doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json')) + lock_path = tmp_path / YARN_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('# yarn lockfile v1\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_yarn.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{YARN_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_yarn: RestoreYarnDependencies, tmp_path: Path) -> None: + lock_content = '# yarn lockfile v1\n\npackage@1.0.0:\n resolved "https://example.com"\n' + (tmp_path / 'package.json').write_text('{"name": "test"}') + lock_path = tmp_path / YARN_LOCK_FILE_NAME + lock_path.write_text(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 lock_path.exists(), f'Pre-existing {YARN_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/nuget/__init__.py b/tests/cli/files_collector/sca/nuget/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/nuget/test_restore_nuget_dependencies.py b/tests/cli/files_collector/sca/nuget/test_restore_nuget_dependencies.py new file mode 100644 index 00000000..0ec13441 --- /dev/null +++ b/tests/cli/files_collector/sca/nuget/test_restore_nuget_dependencies.py @@ -0,0 +1,89 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.nuget.restore_nuget_dependencies import ( + NUGET_LOCK_FILE_NAME, + RestoreNugetDependencies, +) +from cycode.cli.models import Document + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +@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_nuget(mock_ctx: typer.Context) -> RestoreNugetDependencies: + return RestoreNugetDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_csproj_matches(self, restore_nuget: RestoreNugetDependencies) -> None: + doc = Document('MyProject.csproj', '') + assert restore_nuget.is_project(doc) is True + + def test_vbproj_matches(self, restore_nuget: RestoreNugetDependencies) -> None: + doc = Document('MyProject.vbproj', '') + assert restore_nuget.is_project(doc) is True + + def test_sln_does_not_match(self, restore_nuget: RestoreNugetDependencies) -> None: + doc = Document('MySolution.sln', '') + assert restore_nuget.is_project(doc) is False + + def test_packages_json_does_not_match(self, restore_nuget: RestoreNugetDependencies) -> None: + doc = Document('packages.json', '{}') + assert restore_nuget.is_project(doc) is False + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_nuget: RestoreNugetDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'MyProject.csproj').write_text('') + doc = Document( + str(tmp_path / 'MyProject.csproj'), + '', + absolute_path=str(tmp_path / 'MyProject.csproj'), + ) + lock_path = tmp_path / NUGET_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('{"version": 1, "dependencies": {}}') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_nuget.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{NUGET_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_nuget: RestoreNugetDependencies, tmp_path: Path) -> None: + lock_content = '{"version": 1, "dependencies": {"net8.0": {}}}' + (tmp_path / 'MyProject.csproj').write_text('') + lock_path = tmp_path / NUGET_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'MyProject.csproj'), + '', + absolute_path=str(tmp_path / 'MyProject.csproj'), + ) + + result = restore_nuget.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {NUGET_LOCK_FILE_NAME} must not be deleted' 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 index 463eeddb..6e3ea53b 100644 --- a/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py +++ b/tests/cli/files_collector/sca/php/test_restore_composer_dependencies.py @@ -1,5 +1,6 @@ from pathlib import Path -from unittest.mock import MagicMock +from typing import Optional +from unittest.mock import MagicMock, patch import pytest import typer @@ -68,6 +69,54 @@ def test_existing_composer_lock_returned_directly( def test_get_lock_file_name(self, restore_composer: RestoreComposerDependencies) -> None: assert restore_composer.get_lock_file_name() == COMPOSER_LOCK_FILE_NAME + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_composer: RestoreComposerDependencies, tmp_path: Path + ) -> None: + manifest_content = '{"name": "vendor/project"}\n' + (tmp_path / 'composer.json').write_text(manifest_content) + doc = Document(str(tmp_path / 'composer.json'), manifest_content, absolute_path=str(tmp_path / 'composer.json')) + lock_path = tmp_path / COMPOSER_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('{"_readme": [], "packages": []}') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_composer.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{COMPOSER_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted( + 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') + lock_path = tmp_path / COMPOSER_LOCK_FILE_NAME + lock_path.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 lock_path.exists(), f'Pre-existing {COMPOSER_LOCK_FILE_NAME} must not be deleted' + + +class TestGetCommands: def test_get_commands_returns_composer_update(self, restore_composer: RestoreComposerDependencies) -> None: commands = restore_composer.get_commands('/path/to/composer.json') assert commands == [ 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 index 9d34a7e3..a6d97320 100644 --- a/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py +++ b/tests/cli/files_collector/sca/python/test_restore_pipenv_dependencies.py @@ -1,5 +1,6 @@ from pathlib import Path -from unittest.mock import MagicMock +from typing import Optional +from unittest.mock import MagicMock, patch import pytest import typer @@ -71,3 +72,47 @@ def test_get_lock_file_name(self, restore_pipenv: RestorePipenvDependencies) -> def test_get_commands_returns_pipenv_lock(self, restore_pipenv: RestorePipenvDependencies) -> None: commands = restore_pipenv.get_commands('/path/to/Pipfile') assert commands == [['pipenv', 'lock']] + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_pipenv: RestorePipenvDependencies, tmp_path: Path + ) -> None: + manifest_content = '[[source]]\nname = "pypi"\n' + (tmp_path / 'Pipfile').write_text(manifest_content) + doc = Document(str(tmp_path / 'Pipfile'), manifest_content, absolute_path=str(tmp_path / 'Pipfile')) + lock_path = tmp_path / PIPENV_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('{"_meta": {}, "default": {}, "develop": {}}') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_pipenv.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{PIPENV_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted( + 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') + lock_path = tmp_path / PIPENV_LOCK_FILE_NAME + lock_path.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 lock_path.exists(), f'Pre-existing {PIPENV_LOCK_FILE_NAME} must not be deleted' 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 index 73f0d14f..cf4c312b 100644 --- a/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py +++ b/tests/cli/files_collector/sca/python/test_restore_poetry_dependencies.py @@ -1,5 +1,6 @@ from pathlib import Path -from unittest.mock import MagicMock +from typing import Optional +from unittest.mock import MagicMock, patch import pytest import typer @@ -97,3 +98,52 @@ def test_get_lock_file_name(self, restore_poetry: RestorePoetryDependencies) -> 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']] + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_poetry: RestorePoetryDependencies, tmp_path: Path + ) -> None: + # Poetry: no pre-existing poetry.lock but pyproject.toml indicates poetry + manifest_content = '[tool.poetry]\nname = "test"\nversion = "1.0.0"\n' + (tmp_path / 'pyproject.toml').write_text(manifest_content) + doc = Document( + str(tmp_path / 'pyproject.toml'), manifest_content, absolute_path=str(tmp_path / 'pyproject.toml') + ) + lock_path = tmp_path / POETRY_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('# This file is generated by Poetry\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_poetry.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{POETRY_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted( + 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') + lock_path = tmp_path / POETRY_LOCK_FILE_NAME + lock_path.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 lock_path.exists(), f'Pre-existing {POETRY_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/ruby/__init__.py b/tests/cli/files_collector/sca/ruby/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/ruby/test_restore_ruby_dependencies.py b/tests/cli/files_collector/sca/ruby/test_restore_ruby_dependencies.py new file mode 100644 index 00000000..ac3e9d73 --- /dev/null +++ b/tests/cli/files_collector/sca/ruby/test_restore_ruby_dependencies.py @@ -0,0 +1,89 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.ruby.restore_ruby_dependencies import ( + RUBY_LOCK_FILE_NAME, + RestoreRubyDependencies, +) +from cycode.cli.models import Document + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +@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_ruby(mock_ctx: typer.Context) -> RestoreRubyDependencies: + return RestoreRubyDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_gemfile_matches(self, restore_ruby: RestoreRubyDependencies) -> None: + doc = Document('Gemfile', "source 'https://rubygems.org'\n") + assert restore_ruby.is_project(doc) is True + + def test_gemfile_in_subdir_matches(self, restore_ruby: RestoreRubyDependencies) -> None: + doc = Document('myapp/Gemfile', "source 'https://rubygems.org'\n") + assert restore_ruby.is_project(doc) is True + + def test_gemfile_lock_does_not_match(self, restore_ruby: RestoreRubyDependencies) -> None: + doc = Document('Gemfile.lock', 'GEM\n remote: https://rubygems.org/\n') + assert restore_ruby.is_project(doc) is False + + def test_other_file_does_not_match(self, restore_ruby: RestoreRubyDependencies) -> None: + doc = Document('Rakefile', '') + assert restore_ruby.is_project(doc) is False + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_ruby: RestoreRubyDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'Gemfile').write_text("source 'https://rubygems.org'\n") + doc = Document( + str(tmp_path / 'Gemfile'), + "source 'https://rubygems.org'\n", + absolute_path=str(tmp_path / 'Gemfile'), + ) + lock_path = tmp_path / RUBY_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('GEM\n remote: https://rubygems.org/\n specs:\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_ruby.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{RUBY_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_ruby: RestoreRubyDependencies, tmp_path: Path) -> None: + lock_content = 'GEM\n remote: https://rubygems.org/\n specs:\n rake (13.0.6)\n' + (tmp_path / 'Gemfile').write_text("source 'https://rubygems.org'\ngem 'rake'\n") + lock_path = tmp_path / RUBY_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'Gemfile'), + "source 'https://rubygems.org'\ngem 'rake'\n", + absolute_path=str(tmp_path / 'Gemfile'), + ) + + result = restore_ruby.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {RUBY_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/sbt/__init__.py b/tests/cli/files_collector/sca/sbt/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/files_collector/sca/sbt/test_restore_sbt_dependencies.py b/tests/cli/files_collector/sca/sbt/test_restore_sbt_dependencies.py new file mode 100644 index 00000000..415e5f94 --- /dev/null +++ b/tests/cli/files_collector/sca/sbt/test_restore_sbt_dependencies.py @@ -0,0 +1,89 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.sbt.restore_sbt_dependencies import ( + SBT_LOCK_FILE_NAME, + RestoreSbtDependencies, +) +from cycode.cli.models import Document + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +@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_sbt(mock_ctx: typer.Context) -> RestoreSbtDependencies: + return RestoreSbtDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_sbt_file_matches(self, restore_sbt: RestoreSbtDependencies) -> None: + doc = Document('build.sbt', 'name := "my-project"\n') + assert restore_sbt.is_project(doc) is True + + def test_sbt_in_subdir_matches(self, restore_sbt: RestoreSbtDependencies) -> None: + doc = Document('myapp/build.sbt', 'name := "my-project"\n') + assert restore_sbt.is_project(doc) is True + + def test_build_gradle_does_not_match(self, restore_sbt: RestoreSbtDependencies) -> None: + doc = Document('build.gradle', '') + assert restore_sbt.is_project(doc) is False + + def test_pom_xml_does_not_match(self, restore_sbt: RestoreSbtDependencies) -> None: + doc = Document('pom.xml', '') + assert restore_sbt.is_project(doc) is False + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_sbt: RestoreSbtDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'build.sbt').write_text('name := "test"\n') + doc = Document( + str(tmp_path / 'build.sbt'), + 'name := "test"\n', + absolute_path=str(tmp_path / 'build.sbt'), + ) + lock_path = tmp_path / SBT_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('[{"org": "org.typelevel", "name": "cats-core", "version": "2.10.0"}]') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_sbt.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{SBT_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_sbt: RestoreSbtDependencies, tmp_path: Path) -> None: + lock_content = '[{"org": "org.typelevel", "name": "cats-core", "version": "2.10.0"}]' + (tmp_path / 'build.sbt').write_text('name := "test"\n') + lock_path = tmp_path / SBT_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'build.sbt'), + 'name := "test"\n', + absolute_path=str(tmp_path / 'build.sbt'), + ) + + result = restore_sbt.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {SBT_LOCK_FILE_NAME} must not be deleted' diff --git a/tests/cli/files_collector/sca/test_base_restore_dependencies.py b/tests/cli/files_collector/sca/test_base_restore_dependencies.py new file mode 100644 index 00000000..b291a95f --- /dev/null +++ b/tests/cli/files_collector/sca/test_base_restore_dependencies.py @@ -0,0 +1,148 @@ +"""Tests for BaseRestoreDependencies cleanup behavior. + +Verifies that lock files generated by restore commands are deleted after +scanning, while pre-existing lock files are left untouched. +""" + +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies +from cycode.cli.models import Document + +_LOCK_FILE_NAME = 'generated.lock' +_MANIFEST_FILE_NAME = 'manifest.txt' +_LOCK_CONTENT = 'generated lock content' + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class _MinimalRestoreHandler(BaseRestoreDependencies): + """Minimal concrete subclass for directly testing BaseRestoreDependencies.""" + + def is_project(self, document: Document) -> bool: + return document.path.endswith(_MANIFEST_FILE_NAME) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['echo', 'fake']] + + def get_lock_file_name(self) -> str: + return _LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [_LOCK_FILE_NAME] + + +@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 handler(mock_ctx: typer.Context) -> _MinimalRestoreHandler: + return _MinimalRestoreHandler(mock_ctx, is_git_diff=False, command_timeout=30) + + +def _make_doc(tmp_path: Path) -> Document: + manifest = tmp_path / _MANIFEST_FILE_NAME + manifest.write_text('content') + return Document(str(manifest), 'content', absolute_path=str(manifest)) + + +def _make_execute_side_effect(lock_path: Path, content: str = _LOCK_CONTENT) -> object: + """Returns an execute_commands side_effect that writes the lock file.""" + + def side_effect( + commands: list, timeout: int, output_file_path: Optional[str] = None, working_directory: Optional[str] = None + ) -> str: + lock_path.write_text(content) + return 'output' + + return side_effect + + +class TestCleanupGeneratedFile: + def test_generated_lockfile_is_deleted_after_restore(self, handler: _MinimalRestoreHandler, tmp_path: Path) -> None: + doc = _make_doc(tmp_path) + lock_path = tmp_path / _LOCK_FILE_NAME + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=_make_execute_side_effect(lock_path)): + result = handler.try_restore_dependencies(doc) + + assert result is not None + assert result.content == _LOCK_CONTENT + assert not lock_path.exists(), 'Generated lock file must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, handler: _MinimalRestoreHandler, tmp_path: Path) -> None: + doc = _make_doc(tmp_path) + lock_path = tmp_path / _LOCK_FILE_NAME + lock_path.write_text('pre-existing content') + + result = handler.try_restore_dependencies(doc) + + assert result is not None + assert result.content == 'pre-existing content' + assert lock_path.exists(), 'Pre-existing lock file must not be deleted' + + def test_returned_document_content_matches_generated_file( + self, handler: _MinimalRestoreHandler, tmp_path: Path + ) -> None: + doc = _make_doc(tmp_path) + expected = '{"dependencies": {"requests": "^2.31"}}' + lock_path = tmp_path / _LOCK_FILE_NAME + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=_make_execute_side_effect(lock_path, expected)): + result = handler.try_restore_dependencies(doc) + + assert result is not None + assert result.content == expected + + def test_cleanup_does_not_raise_when_generated_file_missing( + self, handler: _MinimalRestoreHandler, tmp_path: Path + ) -> None: + """unlink(missing_ok=True) must not raise even if the command didn't create the file.""" + doc = _make_doc(tmp_path) + + def side_effect(**_kwargs: object) -> str: + return 'output' # returned non-None but didn't create the file + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = handler.try_restore_dependencies(doc) + + # File was never created; content is None but no exception raised + assert result is not None + assert result.content is None + + def test_failed_command_returns_none_and_no_file_created( + self, handler: _MinimalRestoreHandler, tmp_path: Path + ) -> None: + doc = _make_doc(tmp_path) + lock_path = tmp_path / _LOCK_FILE_NAME + + with patch(f'{_BASE_MODULE}.execute_commands', return_value=None): + result = handler.try_restore_dependencies(doc) + + assert result is None + assert not lock_path.exists() + + def test_generated_file_content_available_in_document_after_deletion( + self, handler: _MinimalRestoreHandler, tmp_path: Path + ) -> None: + """The Document must carry the file content even after the file is removed.""" + doc = _make_doc(tmp_path) + lock_path = tmp_path / _LOCK_FILE_NAME + expected = 'important scan data' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=_make_execute_side_effect(lock_path, expected)): + result = handler.try_restore_dependencies(doc) + + assert not lock_path.exists() + assert result is not None + assert result.content == expected From 5bf7393234ac766390ea4bb9ac70ba6a46a8ffc4 Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Mon, 16 Mar 2026 08:46:12 +0000 Subject: [PATCH 260/347] CM-61023: pin build deps (#411) --- .github/workflows/build_executable.yml | 16 ++++++++-------- .github/workflows/docker-image.yml | 21 ++++++++++++--------- .github/workflows/pre_release.yml | 12 ++++++------ .github/workflows/release.yml | 12 ++++++------ .github/workflows/ruff.yml | 13 ++++++++----- .github/workflows/tests.yml | 10 +++++----- .github/workflows/tests_full.yml | 10 +++++----- poetry.lock | 2 +- 8 files changed, 51 insertions(+), 45 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 2807bcf8..e410ba57 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Run Cimon if: matrix.os == 'ubuntu-22.04' - uses: cycodelabs/cimon-action@v0 + uses: cycodelabs/cimon-action@1c3e30d508634b3f4a60b02843126c9f93944d80 # v0.9.4 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} @@ -50,7 +50,7 @@ jobs: uploads.github.com - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 @@ -62,20 +62,20 @@ jobs: echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV - name: Set up Python 3.13 - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.13' - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.local 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 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 @@ -265,14 +265,14 @@ jobs: run: echo "ARTIFACT_NAME=$(./process_executable_file.py dist/cycode-cli)" >> $GITHUB_ENV - name: Upload files as artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: ${{ env.ARTIFACT_NAME }} path: dist - name: Verify macOS artifact end-to-end if: runner.os == 'macOS' && matrix.mode == 'onedir' - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ${{ env.ARTIFACT_NAME }} path: /tmp/artifact-verify @@ -313,7 +313,7 @@ jobs: - name: Upload files to release if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish }} - uses: svenstaro/upload-release-action@v2 + uses: svenstaro/upload-release-action@b98a3b12e86552593f3e4e577ca8a62aa2f3f22b # v2 with: file: dist/* tag: ${{ env.LATEST_TAG }} diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 4e2d4ee8..fe38b63a 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -6,13 +6,16 @@ on: push: tags: [ 'v*.*.*' ] +permissions: + contents: read + jobs: docker: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 @@ -28,20 +31,20 @@ jobs: git checkout ${{ steps.latest_tag.outputs.LATEST_TAG }} - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.9' - name: Load cached Poetry setup id: cached_poetry - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache - name: Setup Poetry if: steps.cached_poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 @@ -58,14 +61,14 @@ jobs: echo "CLI_VERSION=$(poetry version --short)" >> $GITHUB_OUTPUT - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 - name: Login to Docker Hub if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') }} - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 with: username: ${{ secrets.DOCKERHUB_USER }} password: ${{ secrets.DOCKERHUB_PASSWORD }} @@ -73,7 +76,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@v7 + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 with: context: . platforms: linux/amd64,linux/arm64 @@ -83,7 +86,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@v7 + uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 with: context: . platforms: linux/amd64,linux/arm64 diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 802f4e27..f256152a 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@v0 + uses: cycodelabs/cimon-action@1c3e30d508634b3f4a60b02843126c9f93944d80 # v0.9.4 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} @@ -28,25 +28,25 @@ jobs: *.sigstore.dev - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.9' - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 @@ -74,4 +74,4 @@ jobs: run: poetry build - name: Publish a Python distribution to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@106e0b0b7c337fa67ed433972f777c6357f78598 # v1.13.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 88f86ef7..cd922bb0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@v0 + uses: cycodelabs/cimon-action@1c3e30d508634b3f4a60b02843126c9f93944d80 # v0.9.4 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} @@ -27,25 +27,25 @@ jobs: *.sigstore.dev - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.9' - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 @@ -73,4 +73,4 @@ jobs: run: poetry build - name: Publish a Python distribution to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@106e0b0b7c337fa67ed433972f777c6357f78598 # v1.13.0 diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index ae6c7913..3099cbd7 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -2,12 +2,15 @@ name: Ruff (linter and code formatter) on: [ pull_request, push ] +permissions: + contents: read + jobs: ruff: runs-on: ubuntu-latest steps: - name: Run Cimon - uses: cycodelabs/cimon-action@v0 + uses: cycodelabs/cimon-action@1c3e30d508634b3f4a60b02843126c9f93944d80 # v0.9.4 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} @@ -18,23 +21,23 @@ jobs: pypi.org - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Setup Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: 3.9 - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c69fe4ac..cfb1aa21 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@v0 + uses: cycodelabs/cimon-action@1c3e30d508634b3f4a60b02843126c9f93944d80 # v0.9.4 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} @@ -23,23 +23,23 @@ jobs: *.ingest.us.sentry.io - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.9' - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index 65426b13..1fdb091b 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Run Cimon if: matrix.os == 'ubuntu-latest' - uses: cycodelabs/cimon-action@v0 + uses: cycodelabs/cimon-action@1c3e30d508634b3f4a60b02843126c9f93944d80 # v0.9.4 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} @@ -36,25 +36,25 @@ jobs: *.ingest.us.sentry.io - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@v5 + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 with: path: ~/.local 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 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 diff --git a/poetry.lock b/poetry.lock index 0bd73e47..f9bd66ed 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" From 364da74d85e9ea7bcb9fae4201d73a99e1793248 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 08:57:37 +0000 Subject: [PATCH 261/347] Bump pyjwt from 2.10.1 to 2.12.0 (#412) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/poetry.lock b/poetry.lock index f9bd66ed..62539555 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" @@ -1189,14 +1189,14 @@ setuptools = ">=42.0.0" [[package]] name = "pyjwt" -version = "2.10.1" +version = "2.12.0" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, - {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, + {file = "pyjwt-2.12.0-py3-none-any.whl", hash = "sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e"}, + {file = "pyjwt-2.12.0.tar.gz", hash = "sha256:2f62390b667cd8257de560b850bb5a883102a388829274147f1d724453f8fb02"}, ] [package.dependencies] @@ -1204,9 +1204,9 @@ cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"cryp [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"] +dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.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)"] +tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] [[package]] name = "pytest" From 279cc84dce8cfb698d8c45b0f7e499724c6e4b52 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Wed, 18 Mar 2026 11:19:59 +0200 Subject: [PATCH 262/347] CM-60929: Add report mode to ai-guardrails install (#410) Co-authored-by: Claude Opus 4.6 --- cycode/cli/apps/ai_guardrails/__init__.py | 4 + cycode/cli/apps/ai_guardrails/consts.py | 41 ++++-- .../apps/ai_guardrails/ensure_auth_command.py | 21 +++ .../cli/apps/ai_guardrails/hooks_manager.py | 66 ++++++++- .../cli/apps/ai_guardrails/install_command.py | 52 +++++-- .../cli/apps/ai_guardrails/scan/handlers.py | 8 +- .../ai_guardrails/scan/test_payload.py | 2 + .../ai_guardrails/test_hooks_manager.py | 133 +++++++++++++++++- 8 files changed, 295 insertions(+), 32 deletions(-) create mode 100644 cycode/cli/apps/ai_guardrails/ensure_auth_command.py diff --git a/cycode/cli/apps/ai_guardrails/__init__.py b/cycode/cli/apps/ai_guardrails/__init__.py index f8486ed4..11267624 100644 --- a/cycode/cli/apps/ai_guardrails/__init__.py +++ b/cycode/cli/apps/ai_guardrails/__init__.py @@ -1,5 +1,6 @@ import typer +from cycode.cli.apps.ai_guardrails.ensure_auth_command import ensure_auth_command 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 @@ -17,3 +18,6 @@ name='scan', short_help='Scan content from AI IDE hooks for secrets (reads JSON from stdin).', )(scan_command) +app.command(hidden=True, name='ensure-auth', short_help='Ensure authentication, triggering auth if needed.')( + ensure_auth_command +) diff --git a/cycode/cli/apps/ai_guardrails/consts.py b/cycode/cli/apps/ai_guardrails/consts.py index 8714ec10..81539b30 100644 --- a/cycode/cli/apps/ai_guardrails/consts.py +++ b/cycode/cli/apps/ai_guardrails/consts.py @@ -6,6 +6,7 @@ """ import platform +from copy import deepcopy from enum import Enum from pathlib import Path from typing import NamedTuple @@ -25,6 +26,13 @@ class PolicyMode(str, Enum): WARN = 'warn' +class InstallMode(str, Enum): + """Installation mode for ai-guardrails install command.""" + + REPORT = 'report' + BLOCK = 'block' + + class IDEConfig(NamedTuple): """Configuration for an AI IDE.""" @@ -76,12 +84,15 @@ def _get_claude_code_hooks_dir() -> Path: # Command used in hooks CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan' +CYCODE_ENSURE_AUTH_COMMAND = 'cycode ai-guardrails ensure-auth' -def _get_cursor_hooks_config() -> dict: +def _get_cursor_hooks_config(async_mode: bool = False) -> dict: """Get Cursor-specific hooks configuration.""" config = IDE_CONFIGS[AIIDEType.CURSOR] - hooks = {event: [{'command': CYCODE_SCAN_PROMPT_COMMAND}] for event in config.hook_events} + command = f'{CYCODE_SCAN_PROMPT_COMMAND} &' if async_mode else CYCODE_SCAN_PROMPT_COMMAND + hooks = {event: [{'command': command}] for event in config.hook_events} + hooks['sessionStart'] = [{'command': CYCODE_ENSURE_AUTH_COMMAND}] return { 'version': 1, @@ -89,7 +100,7 @@ def _get_cursor_hooks_config() -> dict: } -def _get_claude_code_hooks_config() -> dict: +def _get_claude_code_hooks_config(async_mode: bool = False) -> dict: """Get Claude Code-specific hooks configuration. Claude Code uses a different hook format with nested structure: @@ -98,36 +109,48 @@ def _get_claude_code_hooks_config() -> dict: """ command = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide claude-code' + hook_entry = {'type': 'command', 'command': command} + if async_mode: + hook_entry['async'] = True + hook_entry['timeout'] = 20 + return { 'hooks': { + 'SessionStart': [ + { + 'matcher': 'startup', + 'hooks': [{'type': 'command', 'command': CYCODE_ENSURE_AUTH_COMMAND}], + } + ], 'UserPromptSubmit': [ { - 'hooks': [{'type': 'command', 'command': command}], + 'hooks': [deepcopy(hook_entry)], } ], 'PreToolUse': [ { 'matcher': 'Read', - 'hooks': [{'type': 'command', 'command': command}], + 'hooks': [deepcopy(hook_entry)], }, { 'matcher': 'mcp__.*', - 'hooks': [{'type': 'command', 'command': command}], + 'hooks': [deepcopy(hook_entry)], }, ], }, } -def get_hooks_config(ide: AIIDEType) -> dict: +def get_hooks_config(ide: AIIDEType, async_mode: bool = False) -> dict: """Get the hooks configuration for a specific IDE. Args: ide: The AI IDE type + async_mode: If True, hooks run asynchronously (non-blocking) Returns: Dict with hooks configuration for the specified IDE """ if ide == AIIDEType.CLAUDE_CODE: - return _get_claude_code_hooks_config() - return _get_cursor_hooks_config() + return _get_claude_code_hooks_config(async_mode=async_mode) + return _get_cursor_hooks_config(async_mode=async_mode) diff --git a/cycode/cli/apps/ai_guardrails/ensure_auth_command.py b/cycode/cli/apps/ai_guardrails/ensure_auth_command.py new file mode 100644 index 00000000..78b8bf83 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ensure_auth_command.py @@ -0,0 +1,21 @@ +import typer + +from cycode.cli.apps.auth.auth_common import get_authorization_info +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 + + +def ensure_auth_command(ctx: typer.Context) -> None: + """Ensure the user is authenticated, triggering authentication if needed.""" + auth_info = get_authorization_info(ctx) + if auth_info is not None: + logger.debug('Already authenticated') + return + + logger.debug('Not authenticated, starting authentication') + try: + auth_manager = AuthManager() + auth_manager.authenticate() + except Exception as err: + handle_auth_exception(ctx, err) diff --git a/cycode/cli/apps/ai_guardrails/hooks_manager.py b/cycode/cli/apps/ai_guardrails/hooks_manager.py index b8d43c43..74c681be 100644 --- a/cycode/cli/apps/ai_guardrails/hooks_manager.py +++ b/cycode/cli/apps/ai_guardrails/hooks_manager.py @@ -5,17 +5,21 @@ Supports multiple IDEs: Cursor, Claude Code (future). """ +import copy import json from pathlib import Path from typing import Optional +import yaml + from cycode.cli.apps.ai_guardrails.consts import ( - CYCODE_SCAN_PROMPT_COMMAND, DEFAULT_IDE, IDE_CONFIGS, AIIDEType, + PolicyMode, get_hooks_config, ) +from cycode.cli.apps.ai_guardrails.scan.consts import DEFAULT_POLICY, POLICY_FILE_NAME from cycode.logger import get_logger logger = get_logger('AI Guardrails Hooks') @@ -58,6 +62,13 @@ def save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool: return False +_CYCODE_COMMAND_MARKERS = ('cycode ai-guardrails',) + + +def _is_cycode_command(command: str) -> bool: + return any(marker in command for marker in _CYCODE_COMMAND_MARKERS) + + def is_cycode_hook_entry(entry: dict) -> bool: """Check if a hook entry is from cycode-cli. @@ -68,7 +79,7 @@ def is_cycode_hook_entry(entry: dict) -> bool: """ # Check Cursor format (flat command) command = entry.get('command', '') - if CYCODE_SCAN_PROMPT_COMMAND in command: + if _is_cycode_command(command): return True # Check Claude Code format (nested hooks array) @@ -76,14 +87,58 @@ def is_cycode_hook_entry(entry: dict) -> bool: for hook in hooks: if isinstance(hook, dict): hook_command = hook.get('command', '') - if CYCODE_SCAN_PROMPT_COMMAND in hook_command: + if _is_cycode_command(hook_command): return True return False +def _load_policy(policy_path: Path) -> dict: + """Load existing policy file merged with defaults, or return defaults if not found.""" + if not policy_path.exists(): + return copy.deepcopy(DEFAULT_POLICY) + try: + existing = yaml.safe_load(policy_path.read_text(encoding='utf-8')) or {} + except Exception: + existing = {} + return {**copy.deepcopy(DEFAULT_POLICY), **existing} + + +def create_policy_file(scope: str, mode: PolicyMode, repo_path: Optional[Path] = None) -> tuple[bool, str]: + """Create or update the ai-guardrails.yaml policy file. + + If the file already exists, only the mode field is updated. + If it doesn't exist, a new file is created from the default policy. + + Args: + scope: 'user' for user-level, 'repo' for repository-level + mode: The policy mode to set + repo_path: Repository path (required if scope is 'repo') + + Returns: + Tuple of (success, message) + """ + config_dir = repo_path / '.cycode' if scope == 'repo' and repo_path else Path.home() / '.cycode' + policy_path = config_dir / POLICY_FILE_NAME + + policy = _load_policy(policy_path) + + policy['mode'] = mode.value + + try: + config_dir.mkdir(parents=True, exist_ok=True) + policy_path.write_text(yaml.dump(policy, default_flow_style=False, sort_keys=False), encoding='utf-8') + return True, f'AI guardrails policy ({mode.value} mode) set: {policy_path}' + except Exception as e: + logger.error('Failed to create policy file', exc_info=e) + return False, f'Failed to create policy file: {policy_path}' + + def install_hooks( - scope: str = 'user', repo_path: Optional[Path] = None, ide: AIIDEType = DEFAULT_IDE + scope: str = 'user', + repo_path: Optional[Path] = None, + ide: AIIDEType = DEFAULT_IDE, + report_mode: bool = False, ) -> tuple[bool, str]: """ Install Cycode AI guardrails hooks. @@ -92,6 +147,7 @@ def install_hooks( 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) + report_mode: If True, install hooks in async mode (non-blocking) Returns: Tuple of (success, message) @@ -104,7 +160,7 @@ def install_hooks( existing.setdefault('hooks', {}) # Get IDE-specific hooks configuration - hooks_config = get_hooks_config(ide) + hooks_config = get_hooks_config(ide, async_mode=report_mode) # Add/update Cycode hooks for event, entries in hooks_config['hooks'].items(): diff --git a/cycode/cli/apps/ai_guardrails/install_command.py b/cycode/cli/apps/ai_guardrails/install_command.py index a72d5d4c..a92a978f 100644 --- a/cycode/cli/apps/ai_guardrails/install_command.py +++ b/cycode/cli/apps/ai_guardrails/install_command.py @@ -11,8 +11,8 @@ 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 install_hooks +from cycode.cli.apps.ai_guardrails.consts import IDE_CONFIGS, AIIDEType, InstallMode, PolicyMode +from cycode.cli.apps.ai_guardrails.hooks_manager import create_policy_file, install_hooks def install_command( @@ -43,6 +43,15 @@ def install_command( resolve_path=True, ), ] = None, + mode: Annotated[ + InstallMode, + typer.Option( + '--mode', + '-m', + help='Installation mode: "report" for async non-blocking hooks with warn policy, ' + '"block" for sync blocking hooks.', + ), + ] = InstallMode.REPORT, ) -> None: """Install AI guardrails hooks for supported IDEs. @@ -50,7 +59,8 @@ def install_command( 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 # Install in report mode (default) + cycode ai-guardrails install --mode block # Install in block mode 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 @@ -66,7 +76,8 @@ def install_command( 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) + report_mode = mode == InstallMode.REPORT + success, message = install_hooks(scope, repo_path, ide=current_ide, report_mode=report_mode) results.append((ide_name, success, message)) # Report results for each IDE @@ -81,14 +92,31 @@ def install_command( all_success = False if any_success: - console.print() - console.print('[bold]Next steps:[/]') - 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.[/]') + policy_mode = PolicyMode.WARN if mode == InstallMode.REPORT else PolicyMode.BLOCK + _install_policy(scope, repo_path, policy_mode) + _print_next_steps(results, mode) if not all_success: raise typer.Exit(1) + + +def _install_policy(scope: str, repo_path: Optional[Path], policy_mode: PolicyMode) -> None: + policy_success, policy_message = create_policy_file(scope, policy_mode, repo_path) + if policy_success: + console.print(f'[green]✓[/] {policy_message}') + else: + console.print(f'[red]✗[/] {policy_message}', style='bold red') + + +def _print_next_steps(results: list[tuple[str, bool, str]], mode: InstallMode) -> None: + console.print() + console.print('[bold]Next steps:[/]') + 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() + if mode == InstallMode.REPORT: + console.print('[dim]Report mode: hooks run async (non-blocking) and policy is set to warn.[/]') + else: + console.print('[dim]The hooks will scan prompts, file reads, and MCP tool calls for secrets.[/]') diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 2a762a8d..8c0a2ce7 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -345,12 +345,10 @@ def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) -> 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) + + with open(file_path, encoding='utf-8', errors='replace') as f: + content = f.read(max_bytes) # Get timeout from policy timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) diff --git a/tests/cli/commands/ai_guardrails/scan/test_payload.py b/tests/cli/commands/ai_guardrails/scan/test_payload.py index 27c3010f..e17d833d 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_payload.py +++ b/tests/cli/commands/ai_guardrails/scan/test_payload.py @@ -29,6 +29,7 @@ def test_from_cursor_payload_prompt_event() -> None: assert unified.ide_provider == 'cursor' assert unified.ide_version == '0.42.0' assert unified.prompt == 'Test prompt' + assert type(unified.ide_provider) is str def test_from_cursor_payload_file_read_event() -> None: @@ -153,6 +154,7 @@ def test_from_claude_code_payload_prompt_event() -> None: assert unified.conversation_id == 'session-123' assert unified.ide_provider == 'claude-code' assert unified.prompt == 'Test prompt for Claude Code' + assert type(unified.ide_provider) is str def test_from_claude_code_payload_file_read_event() -> None: diff --git a/tests/cli/commands/ai_guardrails/test_hooks_manager.py b/tests/cli/commands/ai_guardrails/test_hooks_manager.py index f0dec6f7..ed1ada09 100644 --- a/tests/cli/commands/ai_guardrails/test_hooks_manager.py +++ b/tests/cli/commands/ai_guardrails/test_hooks_manager.py @@ -1,6 +1,18 @@ """Tests for AI guardrails hooks manager.""" -from cycode.cli.apps.ai_guardrails.hooks_manager import is_cycode_hook_entry +from pathlib import Path + +import yaml +from pyfakefs.fake_filesystem import FakeFilesystem + +from cycode.cli.apps.ai_guardrails.consts import ( + CYCODE_ENSURE_AUTH_COMMAND, + CYCODE_SCAN_PROMPT_COMMAND, + AIIDEType, + PolicyMode, + get_hooks_config, +) +from cycode.cli.apps.ai_guardrails.hooks_manager import create_policy_file, is_cycode_hook_entry def test_is_cycode_hook_entry_cursor_format() -> None: @@ -51,3 +63,122 @@ def test_is_cycode_hook_entry_partial_match() -> None: entry = {'command': 'cycode ai-guardrails scan --verbose'} assert is_cycode_hook_entry(entry) is True + + +def test_get_hooks_config_cursor_sync() -> None: + """Test Cursor hooks config in default (sync) mode.""" + config = get_hooks_config(AIIDEType.CURSOR) + hooks = config['hooks'] + scan_hooks = {k: v for k, v in hooks.items() if k != 'sessionStart'} + for entries in scan_hooks.values(): + for entry in entries: + assert entry['command'] == CYCODE_SCAN_PROMPT_COMMAND + assert '&' not in entry['command'] + + +def test_get_hooks_config_cursor_async() -> None: + """Test Cursor hooks config in async mode appends & to command.""" + config = get_hooks_config(AIIDEType.CURSOR, async_mode=True) + hooks = config['hooks'] + scan_hooks = {k: v for k, v in hooks.items() if k != 'sessionStart'} + for entries in scan_hooks.values(): + for entry in entries: + assert entry['command'].endswith('&') + assert CYCODE_SCAN_PROMPT_COMMAND in entry['command'] + + +def test_get_hooks_config_cursor_session_start() -> None: + """Test Cursor hooks config includes sessionStart auth check.""" + config = get_hooks_config(AIIDEType.CURSOR) + assert 'sessionStart' in config['hooks'] + entries = config['hooks']['sessionStart'] + assert len(entries) == 1 + assert entries[0]['command'] == CYCODE_ENSURE_AUTH_COMMAND + + +def test_get_hooks_config_claude_code_sync() -> None: + """Test Claude Code hooks config in default (sync) mode.""" + config = get_hooks_config(AIIDEType.CLAUDE_CODE) + scan_events = {k: v for k, v in config['hooks'].items() if k != 'SessionStart'} + for event_entries in scan_events.values(): + for event_entry in event_entries: + for hook in event_entry['hooks']: + assert 'async' not in hook + assert 'timeout' not in hook + + +def test_get_hooks_config_claude_code_async() -> None: + """Test Claude Code hooks config in async mode adds async and timeout.""" + config = get_hooks_config(AIIDEType.CLAUDE_CODE, async_mode=True) + scan_events = {k: v for k, v in config['hooks'].items() if k != 'SessionStart'} + for event_entries in scan_events.values(): + for event_entry in event_entries: + for hook in event_entry['hooks']: + assert hook['async'] is True + + +def test_get_hooks_config_claude_code_session_start() -> None: + """Test Claude Code hooks config includes SessionStart auth check.""" + config = get_hooks_config(AIIDEType.CLAUDE_CODE) + assert 'SessionStart' in config['hooks'] + entries = config['hooks']['SessionStart'] + assert len(entries) == 1 + assert entries[0]['hooks'][0]['command'] == CYCODE_ENSURE_AUTH_COMMAND + + +def test_create_policy_file_warn(fs: FakeFilesystem) -> None: + """Test creating warn-mode policy file.""" + fs.create_dir(Path.home()) + success, message = create_policy_file('user', PolicyMode.WARN) + + assert success is True + assert 'warn mode' in message + + policy_path = Path.home() / '.cycode' / 'ai-guardrails.yaml' + assert policy_path.exists() + + policy = yaml.safe_load(policy_path.read_text()) + assert policy['mode'] == 'warn' + + +def test_create_policy_file_block(fs: FakeFilesystem) -> None: + """Test creating block-mode policy file.""" + fs.create_dir(Path.home()) + success, message = create_policy_file('user', PolicyMode.BLOCK) + + assert success is True + assert 'block mode' in message + + policy_path = Path.home() / '.cycode' / 'ai-guardrails.yaml' + policy = yaml.safe_load(policy_path.read_text()) + assert policy['mode'] == 'block' + + +def test_create_policy_file_updates_existing(fs: FakeFilesystem) -> None: + """Test that re-running only updates mode and preserves other customizations.""" + policy_dir = Path.home() / '.cycode' + fs.create_dir(policy_dir) + policy_path = policy_dir / 'ai-guardrails.yaml' + policy_path.write_text(yaml.dump({'version': 1, 'mode': 'warn', 'custom_field': 'keep_me'})) + + success, _ = create_policy_file('user', PolicyMode.BLOCK) + + assert success is True + policy = yaml.safe_load(policy_path.read_text()) + assert policy['mode'] == 'block' + assert policy['custom_field'] == 'keep_me' + + +def test_create_policy_file_repo_scope(fs: FakeFilesystem) -> None: + """Test creating policy file in repo scope.""" + repo_path = Path('/my-repo') + fs.create_dir(repo_path) + + success, message = create_policy_file('repo', PolicyMode.WARN, repo_path=repo_path) + + assert success is True + policy_path = repo_path / '.cycode' / 'ai-guardrails.yaml' + assert policy_path.exists() + + policy = yaml.safe_load(policy_path.read_text()) + assert policy['mode'] == 'warn' From 6462beacb666f27678c256800aaea663c3d5952e Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Wed, 18 Mar 2026 09:38:13 +0000 Subject: [PATCH 263/347] CM-61023: fix pinning issue (#413) --- .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 e410ba57..9629bfeb 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -75,7 +75,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@v1 with: version: 2.2.1 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index fe38b63a..5442e873 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -44,7 +44,7 @@ jobs: - name: Setup Poetry if: steps.cached_poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@v1 with: version: 2.2.1 diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index f256152a..00ede8ae 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -46,7 +46,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@v1 with: version: 2.2.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd922bb0..5db7c57e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,7 +45,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@v1 with: version: 2.2.1 diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 3099cbd7..56be0e21 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -37,7 +37,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@v1 with: version: 2.2.1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cfb1aa21..297c318d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -39,7 +39,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@v1 with: version: 2.2.1 diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index 1fdb091b..08cf3a9a 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -54,7 +54,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@v1 with: version: 2.2.1 From 71f3690fcd214d2845eb1e0a7378e109bd859950 Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Wed, 18 Mar 2026 13:14:11 +0000 Subject: [PATCH 264/347] CM-61023: update pinned actions (#414) --- .github/workflows/build_executable.yml | 2 +- .github/workflows/docker-image.yml | 2 +- .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 +- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 9629bfeb..e410ba57 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -75,7 +75,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 5442e873..fe38b63a 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -44,7 +44,7 @@ jobs: - name: Setup Poetry if: steps.cached_poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 00ede8ae..0e17facd 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -46,7 +46,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 @@ -74,4 +74,4 @@ jobs: run: poetry build - name: Publish a Python distribution to PyPI - uses: pypa/gh-action-pypi-publish@106e0b0b7c337fa67ed433972f777c6357f78598 # v1.13.0 + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5db7c57e..1a3e3d26 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,7 +45,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 @@ -73,4 +73,4 @@ jobs: run: poetry build - name: Publish a Python distribution to PyPI - uses: pypa/gh-action-pypi-publish@106e0b0b7c337fa67ed433972f777c6357f78598 # v1.13.0 + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 56be0e21..3099cbd7 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -37,7 +37,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 297c318d..cfb1aa21 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -39,7 +39,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index 08cf3a9a..1fdb091b 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -54,7 +54,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@v1 + uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 with: version: 2.2.1 From 49ec71360d3c032639556788ce5355c96b846d24 Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Mon, 23 Mar 2026 12:43:04 +0100 Subject: [PATCH 265/347] CM-61376: Track CLI/IDE activation events (#415) Co-authored-by: Claude Sonnet 4.6 --- cycode/cli/app.py | 2 + cycode/cli/apps/activation_manager.py | 46 +++++++++++++++ cycode/cli/apps/auth/auth_command.py | 8 +++ cycode/cli/apps/scan/scan_command.py | 6 ++ cycode/cli/apps/status/get_cli_status.py | 5 ++ .../cli/user_settings/config_file_manager.py | 11 ++++ .../user_settings/configuration_manager.py | 6 ++ cycode/cyclient/cli_activation_client.py | 14 +++++ .../user_settings/test_activation_tracking.py | 57 +++++++++++++++++++ 9 files changed, 155 insertions(+) create mode 100644 cycode/cli/apps/activation_manager.py create mode 100644 cycode/cyclient/cli_activation_client.py create mode 100644 tests/user_settings/test_activation_tracking.py diff --git a/cycode/cli/app.py b/cycode/cli/app.py index 41391f99..0e9f9c7b 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -166,6 +166,8 @@ def app_callback( if user_agent: user_agent_option = UserAgentOptionScheme().loads(user_agent) CycodeClientBase.enrich_user_agent(user_agent_option.user_agent_suffix) + ctx.obj['plugin_app_name'] = user_agent_option.app_name + ctx.obj['plugin_app_version'] = user_agent_option.app_version if not no_update_notifier: ctx.call_on_close(lambda: check_latest_version_on_close(ctx)) diff --git a/cycode/cli/apps/activation_manager.py b/cycode/cli/apps/activation_manager.py new file mode 100644 index 00000000..8eed3caa --- /dev/null +++ b/cycode/cli/apps/activation_manager.py @@ -0,0 +1,46 @@ +from typing import TYPE_CHECKING, Optional + +from cycode import __version__ +from cycode.cli.config import configuration_manager +from cycode.cyclient.cli_activation_client import CliActivationClient +from cycode.logger import get_logger + +if TYPE_CHECKING: + from cycode.cyclient.cycode_client_base import CycodeClientBase + +logger = get_logger('Activation Manager') + +_CLI_CLIENT_NAME = 'cli' + + +def _get_client_and_version(plugin_app_name: Optional[str], plugin_app_version: Optional[str]) -> tuple[str, str]: + return plugin_app_name or _CLI_CLIENT_NAME, plugin_app_version or __version__ + + +def should_report_cli_activation( + plugin_app_name: Optional[str] = None, + plugin_app_version: Optional[str] = None, +) -> bool: + client, version = _get_client_and_version(plugin_app_name, plugin_app_version) + return configuration_manager.get_last_reported_activation_version(client) != version + + +def report_cli_activation( + cycode_client: 'CycodeClientBase', + plugin_app_name: Optional[str] = None, + plugin_app_version: Optional[str] = None, +) -> None: + """Report CLI/IDE activation to the backend if the (client, version) pair is new. + + Failures are swallowed — activation tracking is non-critical. + """ + try: + client, version = _get_client_and_version(plugin_app_name, plugin_app_version) + + if configuration_manager.get_last_reported_activation_version(client) == version: + return + + CliActivationClient(cycode_client).report_activation() + configuration_manager.update_last_reported_activation_version(client, version) + except Exception: + logger.debug('Failed to report CLI activation', exc_info=True) diff --git a/cycode/cli/apps/auth/auth_command.py b/cycode/cli/apps/auth/auth_command.py index 1184a916..005e8c3e 100644 --- a/cycode/cli/apps/auth/auth_command.py +++ b/cycode/cli/apps/auth/auth_command.py @@ -1,9 +1,11 @@ import typer +from cycode.cli.apps.activation_manager import report_cli_activation, should_report_cli_activation from cycode.cli.apps.auth.auth_manager import AuthManager from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception from cycode.cli.logger import logger from cycode.cli.models import CliResult +from cycode.cli.utils.get_api_client import get_scan_cycode_client def auth_command(ctx: typer.Context) -> None: @@ -23,6 +25,12 @@ def auth_command(ctx: typer.Context) -> None: auth_manager = AuthManager() auth_manager.authenticate() + plugin_app_name = ctx.obj.get('plugin_app_name') + plugin_app_version = ctx.obj.get('plugin_app_version') + if should_report_cli_activation(plugin_app_name, plugin_app_version): + scan_client = get_scan_cycode_client(ctx) + report_cli_activation(scan_client.scan_cycode_client, plugin_app_name, plugin_app_version) + result = CliResult(success=True, message='Successfully logged into cycode') printer.print_result(result) except Exception as err: diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 7aab9d27..56dd2a56 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -5,6 +5,7 @@ import click import typer +from cycode.cli.apps.activation_manager import report_cli_activation, should_report_cli_activation from cycode.cli.apps.sca_options import ( GradleAllSubProjectsOption, MavenSettingsFileOption, @@ -140,6 +141,11 @@ def scan_command( scan_client = get_scan_cycode_client(ctx) ctx.obj['client'] = scan_client + plugin_app_name = ctx.obj.get('plugin_app_name') + plugin_app_version = ctx.obj.get('plugin_app_version') + if should_report_cli_activation(plugin_app_name, plugin_app_version): + report_cli_activation(scan_client.scan_cycode_client, plugin_app_name, plugin_app_version) + # Get remote URL from current working directory remote_url = _try_get_git_remote_url(os.getcwd()) diff --git a/cycode/cli/apps/status/get_cli_status.py b/cycode/cli/apps/status/get_cli_status.py index 0cf6e8fd..7018fa29 100644 --- a/cycode/cli/apps/status/get_cli_status.py +++ b/cycode/cli/apps/status/get_cli_status.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING from cycode import __version__ +from cycode.cli.apps.activation_manager import report_cli_activation, should_report_cli_activation from cycode.cli.apps.auth.auth_common import get_authorization_info from cycode.cli.apps.status.models import CliStatus, CliSupportedModulesStatus from cycode.cli.consts import PROGRAM_NAME @@ -22,7 +23,11 @@ def get_cli_status(ctx: 'Context') -> CliStatus: supported_modules_status = CliSupportedModulesStatus() if is_authenticated: try: + plugin_app_name = ctx.obj.get('plugin_app_name') + plugin_app_version = ctx.obj.get('plugin_app_version') client = get_scan_cycode_client(ctx) + if should_report_cli_activation(plugin_app_name, plugin_app_version): + report_cli_activation(client.scan_cycode_client, plugin_app_name, plugin_app_version) supported_modules_preferences = client.get_supported_modules_preferences() supported_modules_status.secret_scanning = supported_modules_preferences.secret_scanning diff --git a/cycode/cli/user_settings/config_file_manager.py b/cycode/cli/user_settings/config_file_manager.py index 5b029e39..cfab38d2 100644 --- a/cycode/cli/user_settings/config_file_manager.py +++ b/cycode/cli/user_settings/config_file_manager.py @@ -18,6 +18,7 @@ class ConfigFileManager(BaseFileManager): SCAN_SECTION_NAME: str = 'scan' INSTALLATION_ID_FIELD_NAME: str = 'installation_id' + LAST_REPORTED_ACTIVATION_VERSIONS_FIELD_NAME: str = 'last_reported_activation_versions' API_URL_FIELD_NAME: str = 'cycode_api_url' APP_URL_FIELD_NAME: str = 'cycode_app_url' VERBOSE_FIELD_NAME: str = 'verbose' @@ -68,6 +69,16 @@ def update_installation_id(self, installation_id: str) -> None: update_data = {self.ENVIRONMENT_SECTION_NAME: {self.INSTALLATION_ID_FIELD_NAME: installation_id}} self.write_content_to_file(update_data) + def get_last_reported_activation_versions(self) -> dict[str, str]: + value = self._get_value_from_environment_section(self.LAST_REPORTED_ACTIVATION_VERSIONS_FIELD_NAME) + return value if isinstance(value, dict) else {} + + def update_last_reported_activation_version(self, client: str, version: str) -> None: + versions = self.get_last_reported_activation_versions() + versions[client] = version + update_data = {self.ENVIRONMENT_SECTION_NAME: {self.LAST_REPORTED_ACTIVATION_VERSIONS_FIELD_NAME: versions}} + self.write_content_to_file(update_data) + def add_exclusion(self, scan_type: str, exclusion_type: str, new_exclusion: str) -> None: exclusions = self._get_exclusions_by_exclusion_type(scan_type, exclusion_type) if new_exclusion in exclusions: diff --git a/cycode/cli/user_settings/configuration_manager.py b/cycode/cli/user_settings/configuration_manager.py index 689ec0d5..f80f9a6e 100644 --- a/cycode/cli/user_settings/configuration_manager.py +++ b/cycode/cli/user_settings/configuration_manager.py @@ -94,6 +94,12 @@ def get_or_create_installation_id(self) -> str: return installation_id + def get_last_reported_activation_version(self, client: str) -> Optional[str]: + return self.global_config_file_manager.get_last_reported_activation_versions().get(client) + + def update_last_reported_activation_version(self, client: str, version: str) -> None: + self.global_config_file_manager.update_last_reported_activation_version(client, version) + def get_config_file_manager(self, scope: Optional[str] = None) -> ConfigFileManager: if scope == 'local': return self.local_config_file_manager diff --git a/cycode/cyclient/cli_activation_client.py b/cycode/cyclient/cli_activation_client.py new file mode 100644 index 00000000..2932c353 --- /dev/null +++ b/cycode/cyclient/cli_activation_client.py @@ -0,0 +1,14 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from cycode.cyclient.cycode_client_base import CycodeClientBase + +_CLI_ACTIVATION_PATH = 'scans/api/v4/cli-activation' + + +class CliActivationClient: + def __init__(self, cycode_client: 'CycodeClientBase') -> None: + self._cycode_client = cycode_client + + def report_activation(self) -> None: + self._cycode_client.put(url_path=_CLI_ACTIVATION_PATH) diff --git a/tests/user_settings/test_activation_tracking.py b/tests/user_settings/test_activation_tracking.py new file mode 100644 index 00000000..5c8faf02 --- /dev/null +++ b/tests/user_settings/test_activation_tracking.py @@ -0,0 +1,57 @@ +from pathlib import Path + +import pytest + +from cycode.cli.user_settings.config_file_manager import ConfigFileManager + + +@pytest.fixture +def config_manager(tmp_path: Path) -> ConfigFileManager: + return ConfigFileManager(tmp_path) + + +def test_get_last_reported_activation_versions_returns_empty_when_not_set( + config_manager: ConfigFileManager, +) -> None: + assert config_manager.get_last_reported_activation_versions() == {} + + +def test_update_and_get_last_reported_activation_version_cli(config_manager: ConfigFileManager) -> None: + config_manager.update_last_reported_activation_version('cli', '1.10.7') + + assert config_manager.get_last_reported_activation_versions() == {'cli': '1.10.7'} + + +def test_update_and_get_last_reported_activation_version_plugin(config_manager: ConfigFileManager) -> None: + config_manager.update_last_reported_activation_version('vscode_extension', '2.0.0') + + assert config_manager.get_last_reported_activation_versions() == {'vscode_extension': '2.0.0'} + + +def test_update_last_reported_activation_version_multiple_clients(config_manager: ConfigFileManager) -> None: + config_manager.update_last_reported_activation_version('cli', '1.10.7') + config_manager.update_last_reported_activation_version('vscode_extension', '2.0.0') + config_manager.update_last_reported_activation_version('jetbrains_extension', '1.5.0') + + assert config_manager.get_last_reported_activation_versions() == { + 'cli': '1.10.7', + 'vscode_extension': '2.0.0', + 'jetbrains_extension': '1.5.0', + } + + +def test_update_last_reported_activation_version_overwrites_existing(config_manager: ConfigFileManager) -> None: + config_manager.update_last_reported_activation_version('cli', '1.10.7') + config_manager.update_last_reported_activation_version('cli', '1.10.8') + + assert config_manager.get_last_reported_activation_versions() == {'cli': '1.10.8'} + + +def test_update_last_reported_activation_version_does_not_affect_other_clients( + config_manager: ConfigFileManager, +) -> None: + config_manager.update_last_reported_activation_version('cli', '1.10.7') + config_manager.update_last_reported_activation_version('vscode_extension', '2.0.0') + config_manager.update_last_reported_activation_version('cli', '1.10.8') + + assert config_manager.get_last_reported_activation_versions()['vscode_extension'] == '2.0.0' From 9d95dca1644c49deb19f4a1a664c50cc73f08adf Mon Sep 17 00:00:00 2001 From: omerr-cycode Date: Thu, 26 Mar 2026 16:58:21 +0200 Subject: [PATCH 266/347] CM-61587 MCP scan improvements (#418) --- README.md | 38 +++++- cycode/cli/apps/mcp/mcp_command.py | 182 ++++++++++++++++++------- tests/cli/apps/mcp/test_mcp_command.py | 90 +++++++++++- 3 files changed, 255 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index b512c813..dbe3b40b 100644 --- a/README.md +++ b/README.md @@ -384,12 +384,22 @@ 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_secret_scan` | Scan for hardcoded secrets | +| `cycode_sca_scan` | Scan for Software Composition Analysis (SCA) - vulnerabilities and license issues | +| `cycode_iac_scan` | Scan for Infrastructure as Code (IaC) misconfigurations | +| `cycode_sast_scan` | Scan for Static Application Security Testing (SAST) - code quality and security flaws | | `cycode_status` | Get Cycode CLI version, authentication status, and configuration information | +Each scan tool accepts two mutually exclusive input modes: + +- **`paths`** *(preferred)* — one or more file or directory paths that exist on disk. Directories are scanned recursively. The Cycode engine handles file discovery and filtering, just as `cycode scan -t path ./src` does from the CLI. +- **`files`** *(fallback)* — a dictionary mapping file paths to their full content as strings. Use this only when the files are not available on disk (e.g. in-memory edits not yet saved). + +> [!TIP] +> Use `paths` whenever possible. Passing large files (like `package-lock.json`) as inline content can exceed token limits and slow down the AI client. With `paths`, the Cycode engine reads files directly from disk. + +All scan tools return a JSON object that includes a `"summary"` field with a human-readable violation count (e.g. `"Cycode found 3 violations: 1 CRITICAL, 2 HIGH."`) in addition to the full `"detections"` array. + ### Usage Examples #### Basic Command Examples @@ -547,6 +557,26 @@ cycode mcp -t streamable-http -H 127.0.0.2 -p 9000 & > [!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. +### Pre-authorizing Tools for Subagents (Claude Code) + +When Claude Code delegates work to background subagents (e.g. to run scans in parallel), those subagents cannot display interactive permission prompts. If the Cycode tools have not been pre-approved, scans will fail silently in subagent contexts. + +To pre-authorize the Cycode MCP tools so they work in all contexts including subagents, add them to the `allowedTools` list in your Claude Code settings (`~/.claude/settings.json`): + +```json +{ + "allowedTools": [ + "mcp__cycode__cycode_secret_scan", + "mcp__cycode__cycode_sca_scan", + "mcp__cycode__cycode_iac_scan", + "mcp__cycode__cycode_sast_scan", + "mcp__cycode__cycode_status" + ] +} +``` + +Once added, Claude Code will not prompt for approval when these tools are called, and they will work correctly inside subagents. + ### 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: diff --git a/cycode/cli/apps/mcp/mcp_command.py b/cycode/cli/apps/mcp/mcp_command.py index 39bcce40..adfc0a3f 100644 --- a/cycode/cli/apps/mcp/mcp_command.py +++ b/cycode/cli/apps/mcp/mcp_command.py @@ -6,7 +6,7 @@ import sys import tempfile import uuid -from typing import Annotated, Any +from typing import Annotated, Any, Optional import typer from pathvalidate import sanitize_filepath @@ -28,7 +28,25 @@ _DEFAULT_RUN_COMMAND_TIMEOUT = 10 * 60 -_FILES_TOOL_FIELD = Field(description='Files to scan, mapping file paths to their content') +_FILES_TOOL_FIELD = Field( + default=None, + description=( + 'Files to scan, mapping file paths to their content. ' + 'Provide either this or "paths". ' + 'Note: for large codebases, prefer "paths" to avoid token overhead.' + ), +) +_PATHS_TOOL_FIELD = Field( + default=None, + description=( + 'Paths to scan — file paths or directory paths that exist on disk. ' + 'Directories are scanned recursively. ' + 'Provide either this or "files". ' + 'Preferred over "files" when the files already exist on disk.' + ), +) + +_SEVERITY_ORDER = ('CRITICAL', 'HIGH', 'MEDIUM', 'LOW') def _is_debug_mode() -> bool: @@ -163,9 +181,9 @@ def __exit__(self, *_) -> None: shutil.rmtree(self.temp_base_dir, ignore_errors=True) -async def _run_cycode_scan(scan_type: ScanTypeOption, temp_files: list[str]) -> dict[str, Any]: +async def _run_cycode_scan(scan_type: ScanTypeOption, paths: 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]) + return await _run_cycode_command(*['scan', '-t', str(scan_type), 'path', *paths]) async def _run_cycode_status() -> dict[str, Any]: @@ -173,38 +191,89 @@ async def _run_cycode_status() -> dict[str, Any]: return await _run_cycode_command('status') -async def _cycode_scan_tool(scan_type: ScanTypeOption, files: dict[str, str] = _FILES_TOOL_FIELD) -> str: +def _build_scan_summary(result: dict[str, Any]) -> str: + """Build a human-readable summary line from a scan result dict. + + Args: + result: Parsed JSON scan result from the CLI. + + Returns: + A one-line summary string describing what was found. + """ + detections = result.get('detections', []) + errors = result.get('errors', []) + + if not detections: + if errors: + return f'Scan completed with {len(errors)} error(s) and no violations found.' + return 'No violations found.' + + total = len(detections) + severity_counts: dict[str, int] = {} + for d in detections: + sev = (d.get('severity') or 'UNKNOWN').upper() + severity_counts[sev] = severity_counts.get(sev, 0) + 1 + + parts = [f'{severity_counts[s]} {s}' for s in _SEVERITY_ORDER if s in severity_counts] + other_keys = [k for k in severity_counts if k not in _SEVERITY_ORDER] + parts += [f'{severity_counts[k]} {k}' for k in other_keys] + + label = 'violation' if total == 1 else 'violations' + return f'Cycode found {total} {label}: {", ".join(parts)}.' + + +async def _cycode_scan_tool( + scan_type: ScanTypeOption, + files: Optional[dict[str, str]] = None, + paths: Optional[list[str]] = None, +) -> 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'}) + if not files and not paths: + _logger.error('No files or paths provided for scan') + return json.dumps( + {'error': 'No files or paths provided. Pass file contents via "files" or disk paths via "paths".'} + ) try: - 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, - }, - ) + if paths: + missing = [p for p in paths if not os.path.exists(p)] + if missing: + return json.dumps({'error': f'Paths not found on disk: {missing}'}, indent=2) _logger.info( - 'Running Cycode scan, %s', - {'scan_type': scan_type, 'files_count': processed_count, 'call_id': _tool_call_id}, + 'Running Cycode scan (path-based), %s', + {'scan_type': scan_type, 'paths': paths, 'call_id': _tool_call_id}, ) - result = await _run_cycode_scan(scan_type, temp_files) + result = await _run_cycode_scan(scan_type, paths) + else: + 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 (files-based), %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) + if 'error' not in result: + result['summary'] = _build_scan_summary(result) + + _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) @@ -213,8 +282,11 @@ async def _cycode_scan_tool(scan_type: ScanTypeOption, files: dict[str, str] = _ return json.dumps({'error': f'Scan failed: {e!s}'}, indent=2) -async def cycode_secret_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: - """Scan files for hardcoded secrets. +async def cycode_secret_scan( + paths: Optional[list[str]] = _PATHS_TOOL_FIELD, + files: Optional[dict[str, str]] = _FILES_TOOL_FIELD, +) -> str: + """Scan for hardcoded secrets. Use this tool when you need to: - scan code for hardcoded secrets, API keys, passwords, tokens @@ -222,16 +294,20 @@ async def cycode_secret_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: - detect potential security vulnerabilities from secret exposure Args: - files: Dictionary mapping file paths to their content + paths: File or directory paths on disk to scan (preferred). Directories are scanned recursively. + files: Dictionary mapping file paths to their content (fallback when files are not on disk). Returns: - JSON string containing scan results and any secrets found + JSON string with a "summary" field (human-readable violation count) plus full scan results. """ - return await _cycode_scan_tool(ScanTypeOption.SECRET, files) + return await _cycode_scan_tool(ScanTypeOption.SECRET, files=files, paths=paths) -async def cycode_sca_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: - """Scan files for Software Composition Analysis (SCA) - vulnerabilities and license issues. +async def cycode_sca_scan( + paths: Optional[list[str]] = _PATHS_TOOL_FIELD, + files: Optional[dict[str, str]] = _FILES_TOOL_FIELD, +) -> str: + """Scan for Software Composition Analysis (SCA) - vulnerabilities and license issues. Use this tool when you need to: - scan dependencies for known security vulnerabilities @@ -242,19 +318,24 @@ async def cycode_sca_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: 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. + When using "paths", pass the directory containing both manifest and lock files. + When using "files", provide both manifest and lock files together. Args: - files: Dictionary mapping file paths to their content + paths: File or directory paths on disk to scan (preferred). Directories are scanned recursively. + files: Dictionary mapping file paths to their content (fallback when files are not on disk). Returns: - JSON string containing scan results, vulnerabilities, and license issues found + JSON string with a "summary" field (human-readable violation count) plus full scan results. """ - return await _cycode_scan_tool(ScanTypeOption.SCA, files) + return await _cycode_scan_tool(ScanTypeOption.SCA, files=files, paths=paths) -async def cycode_iac_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: - """Scan files for Infrastructure as Code (IaC) misconfigurations. +async def cycode_iac_scan( + paths: Optional[list[str]] = _PATHS_TOOL_FIELD, + files: Optional[dict[str, str]] = _FILES_TOOL_FIELD, +) -> str: + """Scan for Infrastructure as Code (IaC) misconfigurations. Use this tool when you need to: - scan Terraform, CloudFormation, Kubernetes YAML files @@ -264,16 +345,20 @@ async def cycode_iac_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: - review Docker files for security issues Args: - files: Dictionary mapping file paths to their content + paths: File or directory paths on disk to scan (preferred). Directories are scanned recursively. + files: Dictionary mapping file paths to their content (fallback when files are not on disk). Returns: - JSON string containing scan results and any misconfigurations found + JSON string with a "summary" field (human-readable violation count) plus full scan results. """ - return await _cycode_scan_tool(ScanTypeOption.IAC, files) + return await _cycode_scan_tool(ScanTypeOption.IAC, files=files, paths=paths) -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. +async def cycode_sast_scan( + paths: Optional[list[str]] = _PATHS_TOOL_FIELD, + files: Optional[dict[str, str]] = _FILES_TOOL_FIELD, +) -> str: + """Scan for Static Application Security Testing (SAST) - code quality and security flaws. Use this tool when you need to: - scan source code for security vulnerabilities @@ -283,12 +368,13 @@ async def cycode_sast_scan(files: dict[str, str] = _FILES_TOOL_FIELD) -> str: - find SQL injection, XSS, and other application security issues Args: - files: Dictionary mapping file paths to their content + paths: File or directory paths on disk to scan (preferred). Directories are scanned recursively. + files: Dictionary mapping file paths to their content (fallback when files are not on disk). Returns: - JSON string containing scan results and any security flaws found + JSON string with a "summary" field (human-readable violation count) plus full scan results. """ - return await _cycode_scan_tool(ScanTypeOption.SAST, files) + return await _cycode_scan_tool(ScanTypeOption.SAST, files=files, paths=paths) async def cycode_status() -> str: diff --git a/tests/cli/apps/mcp/test_mcp_command.py b/tests/cli/apps/mcp/test_mcp_command.py index ebcc2373..cbb65b1c 100644 --- a/tests/cli/apps/mcp/test_mcp_command.py +++ b/tests/cli/apps/mcp/test_mcp_command.py @@ -9,6 +9,7 @@ pytest.skip('MCP requires Python 3.10+', allow_module_level=True) from cycode.cli.apps.mcp.mcp_command import ( + _build_scan_summary, _sanitize_file_path, _TempFilesManager, ) @@ -271,15 +272,26 @@ async def slow_communicate() -> tuple[bytes, bytes]: # --- _cycode_scan_tool --- +@pytest.mark.anyio +async def test_cycode_scan_tool_no_files_no_paths() -> 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 or paths provided' in parsed['error'] + + @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, {}) + result = await _cycode_scan_tool(ScanTypeOption.SECRET, files={}) parsed = json.loads(result) assert 'error' in parsed - assert 'No files provided' in parsed['error'] + assert 'No files or paths provided' in parsed['error'] @pytest.mark.anyio @@ -287,9 +299,81 @@ 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'}) + result = await _cycode_scan_tool(ScanTypeOption.SECRET, files={'': 'content'}) + parsed = json.loads(result) + assert 'error' in parsed + + +@pytest.mark.anyio +async def test_cycode_scan_tool_paths_not_found() -> 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, paths=['/nonexistent/path/that/does/not/exist']) parsed = json.loads(result) assert 'error' in parsed + assert 'not found on disk' in parsed['error'] + + +# --- _build_scan_summary --- + + +def test_build_scan_summary_no_detections() -> None: + result = _build_scan_summary({'scan_ids': [], 'detections': [], 'report_urls': [], 'errors': []}) + assert result == 'No violations found.' + + +def test_build_scan_summary_no_detections_with_errors() -> None: + result = _build_scan_summary({'detections': [], 'errors': [{'code': 'E001', 'message': 'oops'}]}) + assert '1 error' in result + assert 'no violations' in result.lower() + + +def test_build_scan_summary_single_violation() -> None: + result = _build_scan_summary({'detections': [{'severity': 'HIGH'}], 'errors': []}) + assert '1 violation' in result + assert 'HIGH' in result + + +def test_build_scan_summary_multiple_severities() -> None: + detections = [ + {'severity': 'CRITICAL'}, + {'severity': 'HIGH'}, + {'severity': 'HIGH'}, + {'severity': 'MEDIUM'}, + ] + result = _build_scan_summary({'detections': detections, 'errors': []}) + assert '4 violations' in result + assert '1 CRITICAL' in result + assert '2 HIGH' in result + assert '1 MEDIUM' in result + + +def test_build_scan_summary_severity_order() -> None: + """CRITICAL should appear before HIGH before MEDIUM before LOW.""" + detections = [ + {'severity': 'LOW'}, + {'severity': 'CRITICAL'}, + {'severity': 'MEDIUM'}, + {'severity': 'HIGH'}, + ] + result = _build_scan_summary({'detections': detections, 'errors': []}) + critical_pos = result.index('CRITICAL') + high_pos = result.index('HIGH') + medium_pos = result.index('MEDIUM') + low_pos = result.index('LOW') + assert critical_pos < high_pos < medium_pos < low_pos + + +def test_build_scan_summary_unknown_severity() -> None: + result = _build_scan_summary({'detections': [{'severity': None}], 'errors': []}) + assert '1 violation' in result + assert 'UNKNOWN' in result + + +def test_build_scan_summary_missing_detections_key() -> None: + result = _build_scan_summary({}) + assert result == 'No violations found.' # --- _create_mcp_server --- From cf6379cfeba69e4ac55ad19ea6d14f5b7247f0d9 Mon Sep 17 00:00:00 2001 From: omerr-cycode Date: Thu, 26 Mar 2026 17:04:24 +0200 Subject: [PATCH 267/347] CM-61547 add stop-on-error flag to stop file collection on errors (#416) --- README.md | 14 ++++ .../cli/apps/report/sbom/path/path_command.py | 5 +- cycode/cli/apps/sca_options.py | 11 +++ cycode/cli/apps/scan/code_scanner.py | 1 + cycode/cli/apps/scan/scan_command.py | 8 ++ cycode/cli/exceptions/custom_exceptions.py | 9 +++ cycode/cli/exceptions/handle_scan_errors.py | 6 ++ cycode/cli/files_collector/path_documents.py | 6 ++ .../files_collector/sca/sca_file_collector.py | 5 ++ .../cli/exceptions/test_handle_scan_errors.py | 1 + .../sca/test_sca_file_collector.py | 79 +++++++++++++++++++ 11 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 tests/cli/files_collector/sca/test_sca_file_collector.py diff --git a/README.md b/README.md index dbe3b40b..780d947b 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ This guide walks you through both installation and usage. 4. [Package Vulnerabilities](#package-vulnerabilities-option) 5. [License Compliance](#license-compliance-option) 6. [Lock Restore](#lock-restore-option) + 7. [Stop on Error](#stop-on-error-option) 2. [Repository Scan](#repository-scan) 1. [Branch Option](#branch-option) 3. [Path Scan](#path-scan) @@ -620,6 +621,7 @@ The Cycode CLI application offers several types of scans so that you can choose | `--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! | +| `--stop-on-error` | Abort the scan if any file collection or dependency restore failure occurs, instead of skipping the failed file and continuing. | | `--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. | @@ -726,6 +728,18 @@ If a lockfile already exists alongside the manifest, Cycode reads it directly wi addSbtPlugin("software.purpledragon" % "sbt-dependency-lock" % "1.5.1") ``` +#### Stop on Error Option + +By default, Cycode continues scanning even if a file cannot be read (e.g. due to a permission error) or a dependency lockfile cannot be generated during an SCA scan. The failed item is skipped with a warning and the scan proceeds with the remaining files. + +Use `--stop-on-error` to change this behaviour: the scan aborts immediately on the first such failure and reports the error. + +```bash +cycode scan -t sca --stop-on-error path ~/home/git/codebase +``` + +This is useful in CI pipelines where a silent failure would produce an incomplete scan result. When `--stop-on-error` is triggered you can either fix the underlying issue or, for SCA restore failures specifically, add `--no-restore` to skip lockfile generation and scan direct dependencies only. + ### 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. diff --git a/cycode/cli/apps/report/sbom/path/path_command.py b/cycode/cli/apps/report/sbom/path/path_command.py index a3ffa578..5f0f625a 100644 --- a/cycode/cli/apps/report/sbom/path/path_command.py +++ b/cycode/cli/apps/report/sbom/path/path_command.py @@ -10,6 +10,7 @@ GradleAllSubProjectsOption, MavenSettingsFileOption, NoRestoreOption, + StopOnErrorOption, apply_sca_restore_options_to_context, ) from cycode.cli.exceptions.handle_report_sbom_errors import handle_report_exception @@ -30,8 +31,9 @@ def path_command( no_restore: NoRestoreOption = False, gradle_all_sub_projects: GradleAllSubProjectsOption = False, maven_settings_file: MavenSettingsFileOption = None, + stop_on_error: StopOnErrorOption = False, ) -> None: - apply_sca_restore_options_to_context(ctx, no_restore, gradle_all_sub_projects, maven_settings_file) + apply_sca_restore_options_to_context(ctx, no_restore, gradle_all_sub_projects, maven_settings_file, stop_on_error) client = get_report_cycode_client(ctx) report_parameters = ctx.obj['report_parameters'] @@ -51,6 +53,7 @@ def path_command( consts.SCA_SCAN_TYPE, (str(path),), is_cycodeignore_allowed=is_cycodeignore_allowed_by_scan_config(ctx), + stop_on_error=stop_on_error, ) # 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/sca_options.py b/cycode/cli/apps/sca_options.py index 3c904ee6..01def411 100644 --- a/cycode/cli/apps/sca_options.py +++ b/cycode/cli/apps/sca_options.py @@ -35,13 +35,24 @@ ), ] +StopOnErrorOption = Annotated[ + bool, + typer.Option( + '--stop-on-error', + help='When specified, stops the process if any file collection or restore failure occurs.', + 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], + stop_on_error: bool = False, ) -> 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 + ctx.obj['stop_on_error'] = stop_on_error diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index 616f22b3..4e551f68 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -58,6 +58,7 @@ def scan_disk_files(ctx: typer.Context, paths: tuple[str, ...]) -> None: scan_type, paths, is_cycodeignore_allowed=is_cycodeignore_allowed_by_scan_config(ctx), + stop_on_error=ctx.obj.get('stop_on_error', False), ) # Add entrypoint.cycode file at root path to mark the scan root (only for single path that is a directory) diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 56dd2a56..62697357 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -41,6 +41,13 @@ def scan_command( soft_fail: Annotated[ bool, typer.Option('--soft-fail', help='Run the scan without failing; always return a non-error status code.') ] = False, + stop_on_error: Annotated[ + bool, + typer.Option( + '--stop-on-error', + help='When specified, stops the scan if any file collection or restore failure occurs.', + ), + ] = False, severity_threshold: Annotated[ SeverityOption, typer.Option( @@ -131,6 +138,7 @@ def scan_command( ctx.obj['show_secret'] = show_secret ctx.obj['soft_fail'] = soft_fail + ctx.obj['stop_on_error'] = stop_on_error ctx.obj['scan_type'] = scan_type ctx.obj['sync'] = sync ctx.obj['severity_threshold'] = severity_threshold diff --git a/cycode/cli/exceptions/custom_exceptions.py b/cycode/cli/exceptions/custom_exceptions.py index 78781914..1200e559 100644 --- a/cycode/cli/exceptions/custom_exceptions.py +++ b/cycode/cli/exceptions/custom_exceptions.py @@ -64,6 +64,15 @@ def __str__(self) -> str: return f'The size of zip to scan is too large, size limit: {self.size_limit}' +class FileCollectionError(CycodeError): + def __init__(self, error_message: str) -> None: + self.error_message = error_message + super().__init__(self.error_message) + + def __str__(self) -> str: + return self.error_message + + class AuthProcessError(CycodeError): def __init__(self, error_message: str) -> None: self.error_message = error_message diff --git a/cycode/cli/exceptions/handle_scan_errors.py b/cycode/cli/exceptions/handle_scan_errors.py index 229e0f02..56af186c 100644 --- a/cycode/cli/exceptions/handle_scan_errors.py +++ b/cycode/cli/exceptions/handle_scan_errors.py @@ -26,6 +26,12 @@ def handle_scan_exception(ctx: typer.Context, err: Exception, *, return_exceptio 'Please try ignoring irrelevant paths using the `cycode ignore --by-path` command ' 'and execute the scan again', ), + custom_exceptions.FileCollectionError: CliError( + soft_fail=False, + code='file_collection_error', + message='File collection failed. ' + 'Use --no-restore to skip dependency restoration, or fix the underlying issue.', + ), custom_exceptions.TfplanKeyError: CliError( soft_fail=True, code='key_error', diff --git a/cycode/cli/files_collector/path_documents.py b/cycode/cli/files_collector/path_documents.py index 142c63bf..17f7dd41 100644 --- a/cycode/cli/files_collector/path_documents.py +++ b/cycode/cli/files_collector/path_documents.py @@ -2,6 +2,7 @@ from collections.abc import Generator from typing import TYPE_CHECKING +from cycode.cli.exceptions.custom_exceptions import FileCollectionError from cycode.cli.files_collector.file_excluder import excluder from cycode.cli.files_collector.iac.tf_content_generator import ( generate_tf_content_from_tfplan, @@ -109,6 +110,7 @@ def get_relevant_documents( *, is_git_diff: bool = False, is_cycodeignore_allowed: bool = True, + stop_on_error: bool = False, ) -> list[Document]: relevant_files = _get_relevant_files( progress_bar, progress_bar_section, scan_type, paths, is_cycodeignore_allowed=is_cycodeignore_allowed @@ -119,6 +121,10 @@ def get_relevant_documents( progress_bar.update(progress_bar_section) content = get_file_content(file) + if content is None: + if stop_on_error: + raise FileCollectionError(f'Failed to read file: {file}') + continue if not content: continue diff --git a/cycode/cli/files_collector/sca/sca_file_collector.py b/cycode/cli/files_collector/sca/sca_file_collector.py index b194deef..c9c17ebf 100644 --- a/cycode/cli/files_collector/sca/sca_file_collector.py +++ b/cycode/cli/files_collector/sca/sca_file_collector.py @@ -4,6 +4,7 @@ import typer from cycode.cli import consts +from cycode.cli.exceptions.custom_exceptions import FileCollectionError 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 @@ -116,6 +117,10 @@ def _try_restore_dependencies( 'Error occurred while trying to generate dependencies tree, %s', {'filename': document.path, 'handler': type(restore_dependencies).__name__}, ) + if ctx.obj.get('stop_on_error', False): + raise FileCollectionError( + f'Failed to generate dependencies tree for {document.path} using {type(restore_dependencies).__name__}' + ) return None if restore_dependencies_document.content is None: diff --git a/tests/cli/exceptions/test_handle_scan_errors.py b/tests/cli/exceptions/test_handle_scan_errors.py index ce72e9de..fb14bc8a 100644 --- a/tests/cli/exceptions/test_handle_scan_errors.py +++ b/tests/cli/exceptions/test_handle_scan_errors.py @@ -32,6 +32,7 @@ def ctx() -> typer.Context: (custom_exceptions.HttpUnauthorizedError('msg', Response()), True), (custom_exceptions.ZipTooLargeError(1000), True), (custom_exceptions.TfplanKeyError('msg'), True), + (custom_exceptions.FileCollectionError('Failed to generate dependencies tree for pom.xml'), None), (git_proxy.get_invalid_git_repository_error()(), None), ], ) diff --git a/tests/cli/files_collector/sca/test_sca_file_collector.py b/tests/cli/files_collector/sca/test_sca_file_collector.py new file mode 100644 index 00000000..f645283d --- /dev/null +++ b/tests/cli/files_collector/sca/test_sca_file_collector.py @@ -0,0 +1,79 @@ +from unittest.mock import MagicMock + +import click +import pytest +import typer + +from cycode.cli.exceptions.custom_exceptions import FileCollectionError +from cycode.cli.files_collector.sca.sca_file_collector import _try_restore_dependencies +from cycode.cli.models import Document + + +def _make_ctx(*, stop_on_error: bool = False) -> typer.Context: + ctx = typer.Context(click.Command('path'), obj={'stop_on_error': stop_on_error, 'monitor': False}) + ctx.obj['path'] = '/some/path' + return ctx + + +def _make_handler(*, is_project: bool = True, restore_result: object = None) -> MagicMock: + handler = MagicMock() + handler.is_project.return_value = is_project + handler.restore.return_value = restore_result + return handler + + +class TestTryRestoreDependencies: + def test_returns_none_when_handler_does_not_match(self) -> None: + ctx = _make_ctx() + doc = Document('pom.xml', '', is_git_diff_format=False) + handler = _make_handler(is_project=False) + + result = _try_restore_dependencies(ctx, handler, doc) + + assert result is None + handler.restore.assert_not_called() + + def test_returns_none_on_restore_failure_without_stop_on_error(self) -> None: + ctx = _make_ctx(stop_on_error=False) + doc = Document('pom.xml', '', is_git_diff_format=False) + handler = _make_handler(is_project=True, restore_result=None) + + result = _try_restore_dependencies(ctx, handler, doc) + + assert result is None + + def test_raises_file_collection_error_on_restore_failure_with_stop_on_error(self) -> None: + ctx = _make_ctx(stop_on_error=True) + doc = Document('pom.xml', '', is_git_diff_format=False) + handler = _make_handler(is_project=True, restore_result=None) + handler.__class__.__name__ = 'RestoreMavenDependencies' + type(handler).__name__ = 'RestoreMavenDependencies' + + with pytest.raises(FileCollectionError) as exc_info, ctx: + _try_restore_dependencies(ctx, handler, doc) + + assert 'pom.xml' in str(exc_info.value) + + def test_returns_document_on_success(self) -> None: + ctx = _make_ctx() + doc = Document('pom.xml', '', is_git_diff_format=False) + restored_doc = Document('pom.xml.lock', 'dep-tree-content', is_git_diff_format=False) + handler = _make_handler(is_project=True, restore_result=restored_doc) + + with ctx: + result = _try_restore_dependencies(ctx, handler, doc) + + assert result is restored_doc + assert result.content == 'dep-tree-content' + + def test_sets_empty_content_when_restore_returns_document_with_none_content(self) -> None: + ctx = _make_ctx() + doc = Document('pom.xml', '', is_git_diff_format=False) + restored_doc = Document('pom.xml.lock', None, is_git_diff_format=False) + handler = _make_handler(is_project=True, restore_result=restored_doc) + + with ctx: + result = _try_restore_dependencies(ctx, handler, doc) + + assert result is not None + assert result.content == '' From 6cfc436abae0830e6181af57f0e5ec4b7a79bde4 Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Fri, 27 Mar 2026 10:31:16 +0100 Subject: [PATCH 268/347] CM-61550: Show upload progress and detect slow connections during scan (#419) Co-authored-by: Claude Sonnet 4.6 --- cycode/cli/apps/scan/code_scanner.py | 59 ++++++++++++++- cycode/cli/exceptions/custom_exceptions.py | 11 +++ cycode/cyclient/cycode_client_base.py | 85 ++++++++++++++++++++++ cycode/cyclient/scan_client.py | 52 +++++++++---- tests/cyclient/test_scan_client.py | 27 +++++++ 5 files changed, 216 insertions(+), 18 deletions(-) diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index 4e551f68..35ed1d03 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -47,6 +47,36 @@ logger = get_logger('Code Scanner') +class _UploadProgressAggregator: + """Aggregates upload progress across parallel batch uploads for display in the progress bar.""" + + def __init__(self, progress_bar: 'BaseProgressBar') -> None: + self._progress_bar = progress_bar + self._slots: list[list[int]] = [] + + def create_callback(self) -> Callable[[int, int], None]: + """Create a progress callback for one batch upload. Each batch gets its own slot.""" + slot = [0, 0] + self._slots.append(slot) + + def on_upload_progress(bytes_read: int, total_bytes: int) -> None: + slot[0] = bytes_read + slot[1] = total_bytes + + # Sum across all batch slots to show combined progress + total_read = sum(s[0] for s in self._slots) + total_size = sum(s[1] for s in self._slots) + + if total_read >= total_size: + self._progress_bar.update_right_side_label(None) + else: + mb_read = total_read / (1024 * 1024) + mb_total = total_size / (1024 * 1024) + self._progress_bar.update_right_side_label(f'Uploading {mb_read:.1f} / {mb_total:.1f} MB') + + return on_upload_progress + + def scan_disk_files(ctx: typer.Context, paths: tuple[str, ...]) -> None: scan_type = ctx.obj['scan_type'] progress_bar = ctx.obj['progress_bar'] @@ -121,6 +151,9 @@ def _get_scan_documents_thread_func( severity_threshold = ctx.obj['severity_threshold'] sync_option = ctx.obj['sync'] command_scan_type = ctx.info_name + progress_bar = ctx.obj['progress_bar'] + + aggregator = _UploadProgressAggregator(progress_bar) def _scan_batch_thread_func(batch: list[Document]) -> tuple[str, CliError, LocalScanResult]: local_scan_result = error = error_message = None @@ -143,6 +176,7 @@ def _scan_batch_thread_func(batch: list[Document]) -> tuple[str, CliError, Local is_commit_range, scan_parameters, should_use_sync_flow, + on_upload_progress=aggregator.create_callback(), ) enrich_scan_result_with_data_from_detection_rules(cycode_client, scan_result) @@ -268,11 +302,14 @@ def _perform_scan_v4_async( scan_parameters: dict, is_git_diff: bool, is_commit_range: bool, + on_upload_progress: Optional[Callable] = None, ) -> 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) + cycode_client.upload_to_presigned_post( + upload_link.url, upload_link.presigned_post_fields, zipped_documents, on_upload_progress + ) logger.debug('Uploaded zip to presigned URL') scan_async_result = cycode_client.scan_repository_from_upload_id( @@ -292,9 +329,14 @@ def _perform_scan_async( scan_type: str, scan_parameters: dict, is_commit_range: bool, + on_upload_progress: Optional[Callable] = None, ) -> ZippedFileScanResult: scan_async_result = cycode_client.zipped_file_scan_async( - zipped_documents, scan_type, scan_parameters, is_commit_range=is_commit_range + zipped_documents, + scan_type, + scan_parameters, + is_commit_range=is_commit_range, + on_upload_progress=on_upload_progress, ) logger.debug('Async scan request has been triggered successfully, %s', {'scan_id': scan_async_result.scan_id}) @@ -326,6 +368,7 @@ def _perform_scan( is_commit_range: bool, scan_parameters: dict, should_use_sync_flow: bool = False, + on_upload_progress: Optional[Callable] = None, ) -> ZippedFileScanResult: if should_use_sync_flow: # it does not support commit range scans; should_use_sync_flow handles it @@ -334,12 +377,20 @@ def _perform_scan( if should_use_presigned_upload(scan_type): try: return _perform_scan_v4_async( - cycode_client, zipped_documents, scan_type, scan_parameters, is_git_diff, is_commit_range + cycode_client, + zipped_documents, + scan_type, + scan_parameters, + is_git_diff, + is_commit_range, + on_upload_progress, ) 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) + return _perform_scan_async( + cycode_client, zipped_documents, scan_type, scan_parameters, is_commit_range, on_upload_progress + ) def poll_scan_results( diff --git a/cycode/cli/exceptions/custom_exceptions.py b/cycode/cli/exceptions/custom_exceptions.py index 1200e559..4a874c1f 100644 --- a/cycode/cli/exceptions/custom_exceptions.py +++ b/cycode/cli/exceptions/custom_exceptions.py @@ -55,6 +55,11 @@ def __str__(self) -> str: return f'HTTP unauthorized error occurred during the request. Message: {self.error_message}' +class SlowUploadConnectionError(CycodeError): + def __str__(self) -> str: + return 'Upload was interrupted mid-transfer, indicating a slow or unstable network connection.' + + class ZipTooLargeError(CycodeError): def __init__(self, size_limit: int) -> None: self.size_limit = size_limit @@ -102,6 +107,12 @@ def __str__(self) -> str: code='timeout_error', message='The request timed out. Please try again by executing the `cycode scan` command', ), + SlowUploadConnectionError: CliError( + soft_fail=True, + code='slow_upload_error', + message='The scan upload was interrupted. This is likely due to a slow or unstable network connection. ' + 'Please try again by executing the `cycode scan` command', + ), HttpUnauthorizedError: CliError( soft_fail=True, code='auth_error', diff --git a/cycode/cyclient/cycode_client_base.py b/cycode/cyclient/cycode_client_base.py index 4b2e2698..1aae7bcb 100644 --- a/cycode/cyclient/cycode_client_base.py +++ b/cycode/cyclient/cycode_client_base.py @@ -1,6 +1,7 @@ import os import platform import ssl +from io import BytesIO from typing import TYPE_CHECKING, Callable, ClassVar, Optional import requests @@ -15,6 +16,7 @@ RequestHttpError, RequestSslError, RequestTimeoutError, + SlowUploadConnectionError, ) from cycode.cyclient import config from cycode.cyclient.headers import get_cli_user_agent, get_correlation_id @@ -90,6 +92,23 @@ def _should_retry_exception(exception: BaseException) -> bool: return is_request_error or is_server_error +class UploadProgressTracker: + """File-like wrapper that tracks bytes read during upload and fires a progress callback.""" + + def __init__(self, data: bytes, callback: Optional[Callable[[int, int], None]]) -> None: + self._io = BytesIO(data) + self._callback = callback + self.bytes_read = 0 + self.len = len(data) + + def read(self, size: int = -1) -> bytes: + chunk = self._io.read(size) + self.bytes_read += len(chunk) + if self._callback and chunk: + self._callback(self.bytes_read, self.len) + return chunk + + class CycodeClientBase: MANDATORY_HEADERS: ClassVar[dict[str, str]] = { 'User-Agent': get_cli_user_agent(), @@ -117,6 +136,72 @@ 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) + def post_multipart( + self, + url_path: str, + form_fields: dict, + files: dict, + on_upload_progress: Optional[Callable[[int, int], None]] = None, + hide_response_content_log: bool = False, + ) -> Response: + """POST a multipart form body with optional upload progress tracking and retry.""" + url = self.build_full_url(self.api_url, url_path) + logger.debug('Executing request, %s', {'method': 'POST', 'url': url}) + + # Encode the multipart body once up front so we can reuse the same bytes across retries. + # A dummy URL is used because requests.Request requires one, but only the encoded body matters here. + prepared = requests.Request('POST', 'https://dummy', data=form_fields, files=files).prepare() + + return self._send_multipart( + url=url, + body=prepared.body, + content_type=prepared.headers['Content-Type'], + on_upload_progress=on_upload_progress, + hide_response_content_log=hide_response_content_log, + ) + + @retry( + retry=retry_if_exception(_should_retry_exception), + stop=_RETRY_STOP_STRATEGY, + wait=_RETRY_WAIT_STRATEGY, + reraise=True, + before_sleep=_retry_before_sleep, + ) + def _send_multipart( + self, + url: str, + body: bytes, + content_type: str, + on_upload_progress: Optional[Callable[[int, int], None]], + hide_response_content_log: bool, + ) -> Response: + # Wrap the body in a fresh tracker each attempt so bytes_read starts from zero. + tracker = UploadProgressTracker(body, on_upload_progress) + headers = self.get_request_headers({'Content-Type': content_type}) + try: + response = _get_request_function()( + method='post', url=url, data=tracker, headers=headers, timeout=self.timeout + ) + + content = 'HIDDEN' if hide_response_content_log else response.text + logger.debug( + 'Receiving response, %s', + {'status_code': response.status_code, 'url': url, 'content': content}, + ) + + response.raise_for_status() + return response + except (exceptions.ChunkedEncodingError, exceptions.ConnectionError) as e: + # A connection drop before the full body was sent indicates a slow/unstable network. + if tracker.bytes_read < tracker.len: + raise SlowUploadConnectionError from e + # Full body was sent — map to our types so _should_retry_exception handles retry logic. + if isinstance(e, exceptions.ConnectionError): + raise RequestConnectionError from e + raise + except Exception as e: + self._handle_exception(e) + @retry( retry=retry_if_exception(_should_retry_exception), stop=_RETRY_STOP_STRATEGY, diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index 24c5ac46..b609c4c0 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, Optional, Union +from typing import TYPE_CHECKING, Callable, Optional, Union from uuid import UUID import requests @@ -8,10 +8,14 @@ from cycode.cli import consts from cycode.cli.config import configuration_manager -from cycode.cli.exceptions.custom_exceptions import CycodeError, RequestHttpError +from cycode.cli.exceptions.custom_exceptions import ( + CycodeError, + RequestHttpError, + SlowUploadConnectionError, +) 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.cycode_client_base import CycodeClientBase, UploadProgressTracker from cycode.cyclient.logger import logger if TYPE_CHECKING: @@ -114,18 +118,18 @@ def zipped_file_scan_async( scan_parameters: dict, is_git_diff: bool = False, is_commit_range: bool = False, + on_upload_progress: Optional[Callable[[int, int], None]] = None, ) -> models.ScanInitializationResponse: - files = {'file': ('multiple_files_scan.zip', zip_file.read())} - - response = self.scan_cycode_client.post( + response = self.scan_cycode_client.post_multipart( url_path=self.get_zipped_file_scan_async_url_path(scan_type), - data={ + form_fields={ 'is_git_diff': is_git_diff, 'scan_parameters': json.dumps(scan_parameters), 'is_commit_range': is_commit_range, 'compression_manifest': self._create_compression_manifest_string(zip_file), }, - files=files, + files={'file': ('multiple_files_scan.zip', zip_file.read(), 'application/octet-stream')}, + on_upload_progress=on_upload_progress, ) return models.ScanInitializationResponseSchema().load(response.json()) @@ -135,12 +139,32 @@ def get_upload_link(self, scan_type: str) -> models.UploadLinkResponse: 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 upload_to_presigned_post( + self, + url: str, + fields: dict[str, str], + zip_file: 'InMemoryZip', + on_upload_progress: Optional[Callable[[int, int], None]] = None, + ) -> None: + all_files = {key: (None, value) for key, value in fields.items()} + all_files['file'] = ('multiple_files_scan.zip', zip_file.read(), 'application/octet-stream') + + prepared = requests.Request('POST', 'https://dummy', files=all_files).prepare() + tracker = UploadProgressTracker(prepared.body, on_upload_progress) + + try: + # We are not using Cycode client, as we are calling aws S3. + response = requests.post( + url, + data=tracker, + headers={'Content-Type': prepared.headers['Content-Type']}, + timeout=self.scan_cycode_client.timeout, + ) + response.raise_for_status() + except (requests.exceptions.ChunkedEncodingError, requests.exceptions.ConnectionError) as e: + if tracker.bytes_read < tracker.len: + raise SlowUploadConnectionError from e + raise def scan_repository_from_upload_id( self, diff --git a/tests/cyclient/test_scan_client.py b/tests/cyclient/test_scan_client.py index d6928118..505d8d50 100644 --- a/tests/cyclient/test_scan_client.py +++ b/tests/cyclient/test_scan_client.py @@ -4,6 +4,7 @@ import pytest import requests import responses +from pytest_mock import MockerFixture from requests.exceptions import ConnectionError as RequestsConnectionError from cycode.cli.cli_types import ScanTypeOption @@ -12,6 +13,7 @@ HttpUnauthorizedError, RequestConnectionError, RequestTimeoutError, + SlowUploadConnectionError, ) from cycode.cli.files_collector.models.in_memory_zip import InMemoryZip from cycode.cli.models import Document @@ -168,3 +170,28 @@ def test_get_scan_details( 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' + + +@pytest.mark.parametrize('scan_type', list(ScanTypeOption)) +def test_zipped_file_scan_async_slow_upload_error( + scan_type: ScanTypeOption, scan_client: ScanClient, mocker: MockerFixture +) -> None: + """Test that a connection failure mid-transfer raises SlowUploadConnectionError.""" + zip_file = get_test_zip_file(scan_type) + + def _partial_upload_then_fail(**kwargs) -> None: + # Read only a small portion of the body to simulate a partial upload + data = kwargs.get('data') + if data is not None: + data.read(10) + raise requests.exceptions.ChunkedEncodingError('Connection broken mid-transfer') + + mocker.patch('cycode.cyclient.cycode_client_base._get_request_function', return_value=_partial_upload_then_fail) + mocker.patch.object( + scan_client.scan_cycode_client, + 'get_request_headers', + return_value={'Authorization': 'Bearer test'}, + ) + + with pytest.raises(SlowUploadConnectionError): + scan_client.zipped_file_scan_async(zip_file=zip_file, scan_type=scan_type, scan_parameters={}) From 14c71e0888b7ed5035e4db043e7f1c161be678f8 Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Thu, 2 Apr 2026 13:20:55 +0200 Subject: [PATCH 269/347] CM-62273: Add compression manifest to v4 presigned upload scans (#431) Co-authored-by: Claude Sonnet 4.6 --- cycode/cli/apps/scan/code_scanner.py | 2 +- cycode/cli/apps/scan/commit_range_scanner.py | 2 +- cycode/cyclient/scan_client.py | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index 35ed1d03..072e438e 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -313,7 +313,7 @@ def _perform_scan_v4_async( 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 + scan_type, upload_link.upload_id, zipped_documents, scan_parameters, is_git_diff, is_commit_range ) logger.debug( 'Presigned upload scan request triggered, %s', diff --git a/cycode/cli/apps/scan/commit_range_scanner.py b/cycode/cli/apps/scan/commit_range_scanner.py index d4ce4be8..9691be6e 100644 --- a/cycode/cli/apps/scan/commit_range_scanner.py +++ b/cycode/cli/apps/scan/commit_range_scanner.py @@ -113,7 +113,7 @@ def _perform_commit_range_scan_v4_async( 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 + scan_type, from_upload_link.upload_id, to_upload_link.upload_id, from_commit_zipped_documents, scan_parameters ) logger.debug('V4 commit range scan request triggered, %s', {'scan_id': scan_async_result.scan_id}) diff --git a/cycode/cyclient/scan_client.py b/cycode/cyclient/scan_client.py index b609c4c0..18f400ac 100644 --- a/cycode/cyclient/scan_client.py +++ b/cycode/cyclient/scan_client.py @@ -170,6 +170,7 @@ def scan_repository_from_upload_id( self, scan_type: str, upload_id: str, + zip_file: InMemoryZip, scan_parameters: dict, is_git_diff: bool = False, is_commit_range: bool = False, @@ -183,6 +184,7 @@ def scan_repository_from_upload_id( 'is_git_diff': is_git_diff, 'is_commit_range': is_commit_range, 'scan_parameters': json.dumps(scan_parameters), + 'compression_manifest': self._create_compression_manifest_string(zip_file), }, ) return models.ScanInitializationResponseSchema().load(response.json()) @@ -230,6 +232,7 @@ def commit_range_scan_from_upload_ids( scan_type: str, from_commit_upload_id: str, to_commit_upload_id: str, + from_commit_zip_file: InMemoryZip, scan_parameters: dict, is_git_diff: bool = False, ) -> models.ScanInitializationResponse: @@ -242,6 +245,7 @@ def commit_range_scan_from_upload_ids( 'to_commit_upload_id': to_commit_upload_id, 'is_git_diff': is_git_diff, 'scan_parameters': json.dumps(scan_parameters), + 'compression_manifest': self._create_compression_manifest_string(from_commit_zip_file), }, ) return models.ScanInitializationResponseSchema().load(response.json()) From 2ac16e5d36fcff092e8e10aac47c9ce1174caa14 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:14:33 +0100 Subject: [PATCH 270/347] Bump pytest from 7.3.2 to 8.4.2 (#425) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 27 ++++++++++++++------------- pyproject.toml | 2 +- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/poetry.lock b/poetry.lock index 62539555..108c34d9 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1123,7 +1123,7 @@ version = "2.19.2" description = "Pygments is a syntax highlighting package written in Python." optional = false python-versions = ">=3.8" -groups = ["main"] +groups = ["main", "test"] files = [ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, @@ -1210,26 +1210,27 @@ tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] [[package]] name = "pytest" -version = "7.3.2" +version = "8.4.2" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" 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"}, + {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"}, + {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"}, ] [package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=0.12,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-mock" @@ -1997,4 +1998,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "04201585f115c406a49b035b4c3b3be7057baee685997ab57fe39cc964ad5352" +content-hash = "f8824deab2890da823b848258c784788f97ded6393d417100c9f9fdbd90bc79d" diff --git a/pyproject.toml b/pyproject.toml index 2beebaf2..3fd3af18 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ pathvalidate = ">=3.3.1,<4.0.0" [tool.poetry.group.test.dependencies] mock = ">=4.0.3,<4.1.0" -pytest = ">=7.3.1,<7.4.0" +pytest = ">=7.3.1,<8.5.0" pytest-mock = ">=3.10.0,<3.11.0" coverage = ">=7.2.3,<7.3.0" responses = ">=0.23.1,<0.27.0" From 5c6876ab231ed744485519826e8bdd47db04f607 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Apr 2026 16:50:49 +0100 Subject: [PATCH 271/347] Bump coverage from 7.2.7 to 7.10.7 (#423) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 170 +++++++++++++++++++++++++++++++------------------ pyproject.toml | 2 +- 2 files changed, 108 insertions(+), 64 deletions(-) diff --git a/poetry.lock b/poetry.lock index 108c34d9..3b793f4a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -342,72 +342,116 @@ files = [ [[package]] name = "coverage" -version = "7.2.7" +version = "7.10.7" description = "Code coverage measurement for Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.9" 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"}, - {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"}, + {file = "coverage-7.10.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc04cc7a3db33664e0c2d10eb8990ff6b3536f6842c9590ae8da4c614b9ed05a"}, + {file = "coverage-7.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e201e015644e207139f7e2351980feb7040e6f4b2c2978892f3e3789d1c125e5"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:240af60539987ced2c399809bd34f7c78e8abe0736af91c3d7d0e795df633d17"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8421e088bc051361b01c4b3a50fd39a4b9133079a2229978d9d30511fd05231b"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be8ed3039ae7f7ac5ce058c308484787c86e8437e72b30bf5e88b8ea10f3c87"}, + {file = "coverage-7.10.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e28299d9f2e889e6d51b1f043f58d5f997c373cc12e6403b90df95b8b047c13e"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c4e16bd7761c5e454f4efd36f345286d6f7c5fa111623c355691e2755cae3b9e"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b1c81d0e5e160651879755c9c675b974276f135558cf4ba79fee7b8413a515df"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:606cc265adc9aaedcc84f1f064f0e8736bc45814f15a357e30fca7ecc01504e0"}, + {file = "coverage-7.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:10b24412692df990dbc34f8fb1b6b13d236ace9dfdd68df5b28c2e39cafbba13"}, + {file = "coverage-7.10.7-cp310-cp310-win32.whl", hash = "sha256:b51dcd060f18c19290d9b8a9dd1e0181538df2ce0717f562fff6cf74d9fc0b5b"}, + {file = "coverage-7.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:3a622ac801b17198020f09af3eaf45666b344a0d69fc2a6ffe2ea83aeef1d807"}, + {file = "coverage-7.10.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a609f9c93113be646f44c2a0256d6ea375ad047005d7f57a5c15f614dc1b2f59"}, + {file = "coverage-7.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:65646bb0359386e07639c367a22cf9b5bf6304e8630b565d0626e2bdf329227a"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5f33166f0dfcce728191f520bd2692914ec70fac2713f6bf3ce59c3deacb4699"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:35f5e3f9e455bb17831876048355dca0f758b6df22f49258cb5a91da23ef437d"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4da86b6d62a496e908ac2898243920c7992499c1712ff7c2b6d837cc69d9467e"}, + {file = "coverage-7.10.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6b8b09c1fad947c84bbbc95eca841350fad9cbfa5a2d7ca88ac9f8d836c92e23"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4376538f36b533b46f8971d3a3e63464f2c7905c9800db97361c43a2b14792ab"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:121da30abb574f6ce6ae09840dae322bef734480ceafe410117627aa54f76d82"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:88127d40df529336a9836870436fc2751c339fbaed3a836d42c93f3e4bd1d0a2"}, + {file = "coverage-7.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba58bbcd1b72f136080c0bccc2400d66cc6115f3f906c499013d065ac33a4b61"}, + {file = "coverage-7.10.7-cp311-cp311-win32.whl", hash = "sha256:972b9e3a4094b053a4e46832b4bc829fc8a8d347160eb39d03f1690316a99c14"}, + {file = "coverage-7.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:a7b55a944a7f43892e28ad4bc0561dfd5f0d73e605d1aa5c3c976b52aea121d2"}, + {file = "coverage-7.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:736f227fb490f03c6488f9b6d45855f8e0fd749c007f9303ad30efab0e73c05a"}, + {file = "coverage-7.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7bb3b9ddb87ef7725056572368040c32775036472d5a033679d1fa6c8dc08417"}, + {file = "coverage-7.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:18afb24843cbc175687225cab1138c95d262337f5473512010e46831aa0c2973"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:399a0b6347bcd3822be369392932884b8216d0944049ae22925631a9b3d4ba4c"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314f2c326ded3f4b09be11bc282eb2fc861184bc95748ae67b360ac962770be7"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c41e71c9cfb854789dee6fc51e46743a6d138b1803fab6cb860af43265b42ea6"}, + {file = "coverage-7.10.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc01f57ca26269c2c706e838f6422e2a8788e41b3e3c65e2f41148212e57cd59"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a6442c59a8ac8b85812ce33bc4d05bde3fb22321fa8294e2a5b487c3505f611b"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:78a384e49f46b80fb4c901d52d92abe098e78768ed829c673fbb53c498bef73a"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5e1e9802121405ede4b0133aa4340ad8186a1d2526de5b7c3eca519db7bb89fb"}, + {file = "coverage-7.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d41213ea25a86f69efd1575073d34ea11aabe075604ddf3d148ecfec9e1e96a1"}, + {file = "coverage-7.10.7-cp312-cp312-win32.whl", hash = "sha256:77eb4c747061a6af8d0f7bdb31f1e108d172762ef579166ec84542f711d90256"}, + {file = "coverage-7.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:f51328ffe987aecf6d09f3cd9d979face89a617eacdaea43e7b3080777f647ba"}, + {file = "coverage-7.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:bda5e34f8a75721c96085903c6f2197dc398c20ffd98df33f866a9c8fd95f4bf"}, + {file = "coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d"}, + {file = "coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49"}, + {file = "coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c"}, + {file = "coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f"}, + {file = "coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698"}, + {file = "coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843"}, + {file = "coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546"}, + {file = "coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c"}, + {file = "coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0"}, + {file = "coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999"}, + {file = "coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2"}, + {file = "coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a"}, + {file = "coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb"}, + {file = "coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb"}, + {file = "coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520"}, + {file = "coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360"}, + {file = "coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e"}, + {file = "coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd"}, + {file = "coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2"}, + {file = "coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681"}, + {file = "coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880"}, + {file = "coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63"}, + {file = "coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699"}, + {file = "coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0"}, + {file = "coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399"}, + {file = "coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235"}, + {file = "coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d"}, + {file = "coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a"}, + {file = "coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3"}, + {file = "coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2af88deffcc8a4d5974cf2d502251bc3b2db8461f0b66d80a449c33757aa9f40"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7315339eae3b24c2d2fa1ed7d7a38654cba34a13ef19fbcb9425da46d3dc594"}, + {file = "coverage-7.10.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:912e6ebc7a6e4adfdbb1aec371ad04c68854cd3bf3608b3514e7ff9062931d8a"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f49a05acd3dfe1ce9715b657e28d138578bc40126760efb962322c56e9ca344b"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cce2109b6219f22ece99db7644b9622f54a4e915dad65660ec435e89a3ea7cc3"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:f3c887f96407cea3916294046fc7dab611c2552beadbed4ea901cbc6a40cc7a0"}, + {file = "coverage-7.10.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:635adb9a4507c9fd2ed65f39693fa31c9a3ee3a8e6dc64df033e8fdf52a7003f"}, + {file = "coverage-7.10.7-cp39-cp39-win32.whl", hash = "sha256:5a02d5a850e2979b0a014c412573953995174743a3f7fa4ea5a6e9a3c5617431"}, + {file = "coverage-7.10.7-cp39-cp39-win_amd64.whl", hash = "sha256:c134869d5ffe34547d14e174c866fd8fe2254918cc0a95e99052903bc1543e07"}, + {file = "coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260"}, + {file = "coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239"}, ] [package.extras] @@ -1998,4 +2042,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "f8824deab2890da823b848258c784788f97ded6393d417100c9f9fdbd90bc79d" +content-hash = "25dc6986a2a4572b689edb26f9184faab8bee8e3a569f8e0cdc8ac35ded0b9fc" diff --git a/pyproject.toml b/pyproject.toml index 3fd3af18..0ed8d8c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ pathvalidate = ">=3.3.1,<4.0.0" mock = ">=4.0.3,<4.1.0" pytest = ">=7.3.1,<8.5.0" pytest-mock = ">=3.10.0,<3.11.0" -coverage = ">=7.2.3,<7.3.0" +coverage = ">=7.2.3,<7.11.0" responses = ">=0.23.1,<0.27.0" pyfakefs = ">=5.7.2,<5.11.0" From c404e4caca288a98d2294dcb7f0d06edfaf41ac1 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Tue, 7 Apr 2026 14:22:22 +0300 Subject: [PATCH 272/347] =?UTF-8?q?CM-61568:=20Fix=20sensitive=20path=20sk?= =?UTF-8?q?ipping=20content=20scan=20and=20directory=20hand=E2=80=A6=20(#4?= =?UTF-8?q?32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 4.6 (1M context) --- .../cli/apps/ai_guardrails/scan/handlers.py | 34 ++++-- .../ai_guardrails/scan/test_handlers.py | 103 ++++++++++++++++++ 2 files changed, 128 insertions(+), 9 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 8c0a2ce7..99fa29c8 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -116,7 +116,8 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: try: # Check path-based denylist first - if is_denied_path(file_path, policy): + is_sensitive_path = is_denied_path(file_path, policy) + if is_sensitive_path: block_reason = BlockReason.SENSITIVE_PATH if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK: outcome = AIHookOutcome.BLOCKED @@ -125,13 +126,21 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: user_message, 'This file path is classified as sensitive; do not read/send it to the model.', ) - # Warn mode - ask user for permission + # Warn mode - if content scan is enabled, emit a separate event for the + # sensitive path so the finally block can independently track the scan result. + # If content scan is disabled, a single event (from finally) is enough. 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; proceed with caution.', - ) + if get_policy_value(file_read_config, 'scan_content', default=True): + ai_client.create_event( + payload, + AiHookEventType.FILE_READ, + outcome, + block_reason=BlockReason.SENSITIVE_PATH, + file_path=payload.file_path, + ) + # Reset for the content scan result tracked by the finally block + block_reason = None + outcome = AIHookOutcome.ALLOWED # Scan file content if enabled if get_policy_value(file_read_config, 'scan_content', default=True): @@ -152,7 +161,14 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: user_message, 'Possible secrets detected; proceed with caution.', ) - return response_builder.allow_permission() + + # If path was sensitive but content scan found no secrets (or scan disabled), still warn + if is_sensitive_path: + 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; proceed with caution.', + ) return response_builder.allow_permission() except Exception as e: @@ -342,7 +358,7 @@ def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) -> 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): + if not file_path or not os.path.isfile(file_path): return None, None max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index 1adfe25b..1ef1098c 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -263,6 +263,109 @@ def test_handle_before_read_file_scan_disabled( mock_scan.assert_not_called() +@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_sensitive_path_warn_mode_scans_content( + mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that sensitive path in warn mode still scans file content and emits two events.""" + mock_is_denied.return_value = True + mock_scan.return_value = (None, 'scan-id-123') + default_policy['mode'] = 'warn' + payload = AIHookPayload( + event_name='file_read', + ide_provider='cursor', + file_path='/path/to/.env', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + # Content was scanned even though path is sensitive + mock_scan.assert_called_once() + # Still warns about sensitive path since no secrets found + assert result['permission'] == 'ask' + assert '.env' in result['user_message'] + + # Two events: sensitive path warn + content scan result (allowed, no secrets found) + assert mock_ctx.obj['ai_security_client'].create_event.call_count == 2 + first_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[0] + assert first_event.args[2] == AIHookOutcome.WARNED + assert first_event.kwargs['block_reason'] == BlockReason.SENSITIVE_PATH + second_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[1] + assert second_event.args[2] == AIHookOutcome.ALLOWED + assert second_event.kwargs['block_reason'] is None + + +@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_sensitive_path_warn_mode_with_secrets( + mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that sensitive path in warn mode reports secrets and emits two events.""" + mock_is_denied.return_value = True + mock_scan.return_value = ('Found 1 secret: API key', 'scan-id-456') + default_policy['mode'] = 'warn' + payload = AIHookPayload( + event_name='file_read', + ide_provider='cursor', + file_path='/path/to/.env', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + mock_scan.assert_called_once() + assert result['permission'] == 'ask' + assert 'Found 1 secret: API key' in result['user_message'] + + # Two events: sensitive path warn + secrets warn + assert mock_ctx.obj['ai_security_client'].create_event.call_count == 2 + first_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[0] + assert first_event.args[2] == AIHookOutcome.WARNED + assert first_event.kwargs['block_reason'] == BlockReason.SENSITIVE_PATH + second_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[1] + assert second_event.args[2] == AIHookOutcome.WARNED + assert second_event.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_sensitive_path_scan_disabled_warns( + mock_scan: MagicMock, mock_is_denied: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any] +) -> None: + """Test that sensitive path in warn mode with scan disabled emits a single event.""" + mock_is_denied.return_value = True + default_policy['mode'] = 'warn' + default_policy['file_read']['scan_content'] = False + payload = AIHookPayload( + event_name='file_read', + ide_provider='cursor', + file_path='/path/to/.env', + ) + + result = handle_before_read_file(mock_ctx, payload, default_policy) + + mock_scan.assert_not_called() + assert result['permission'] == 'ask' + assert '.env' in result['user_message'] + + # Single event: sensitive path warn (no separate scan event when scan is disabled) + 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 + assert call_args.kwargs['block_reason'] == BlockReason.SENSITIVE_PATH + + +def test_scan_path_for_secrets_directory(mock_ctx: MagicMock, default_policy: dict[str, Any], fs: Any) -> None: + """Test that _scan_path_for_secrets returns (None, None) for directories.""" + from cycode.cli.apps.ai_guardrails.scan.handlers import _scan_path_for_secrets + + fs.create_dir('/path/to/some_directory') + + result = _scan_path_for_secrets(mock_ctx, '/path/to/some_directory', default_policy) + + assert result == (None, None) + + # Tests for handle_before_mcp_execution From d77820946c8ee5d21ab122eeed7996f54bc1d43a Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Tue, 7 Apr 2026 16:15:07 +0100 Subject: [PATCH 273/347] CM-62406: handle more pre-push types gracefully (#433) --- .../apps/scan/pre_push/pre_push_command.py | 4 ++ .../files_collector/commit_range_documents.py | 21 ++++--- .../test_commit_range_documents.py | 56 +++++++++---------- 3 files changed, 44 insertions(+), 37 deletions(-) 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 d3339ea9..729f3571 100644 --- a/cycode/cli/apps/scan/pre_push/pre_push_command.py +++ b/cycode/cli/apps/scan/pre_push/pre_push_command.py @@ -44,6 +44,10 @@ def pre_push_command( timeout = configuration_manager.get_pre_push_command_timeout(command_scan_type) with TimeoutAfter(timeout): push_update_details = parse_pre_push_input() + if not push_update_details: + logger.info('No pre-push input found, nothing to scan') + return + commit_range = calculate_pre_push_commit_range(push_update_details) if not commit_range: logger.info( diff --git a/cycode/cli/files_collector/commit_range_documents.py b/cycode/cli/files_collector/commit_range_documents.py index a4a1a784..daa5c432 100644 --- a/cycode/cli/files_collector/commit_range_documents.py +++ b/cycode/cli/files_collector/commit_range_documents.py @@ -228,7 +228,7 @@ def parse_pre_receive_input() -> str: return pre_receive_input.splitlines()[0] -def parse_pre_push_input() -> str: +def parse_pre_push_input() -> Optional[str]: """Parse input to pre-push hook details. Example input: @@ -237,13 +237,11 @@ def parse_pre_push_input() -> str: 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) + :return: First push update details (input's first line), or None if no input was provided """ # noqa: E501 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' - ) + return None # each line represents a branch push request, handle the first one only return pre_push_input.splitlines()[0] @@ -332,6 +330,15 @@ def calculate_pre_push_commit_range(push_update_details: str) -> Optional[str]: """ local_ref, local_object_name, remote_ref, remote_object_name = push_update_details.split() + # Tag pushes don't contain file diffs that need scanning + if local_ref.startswith('refs/tags/') or remote_ref.startswith('refs/tags/'): + logger.info('Skipping scan for tag push: %s -> %s', local_ref, remote_ref) + return None + + # If deleting a ref (local_object_name is all zeros), no need to scan + if local_object_name == consts.EMPTY_COMMIT_SHA: + return None + if remote_object_name == consts.EMPTY_COMMIT_SHA: try: repo = git_proxy.get_repo(os.getcwd()) @@ -356,10 +363,6 @@ def calculate_pre_push_commit_range(push_update_details: str) -> Optional[str]: logger.debug('Failed to get repo for pre-push commit range calculation: %s', exc_info=e) 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: - return None - # For updates to existing branches, scan from remote to local return f'{remote_object_name}..{local_object_name}' diff --git a/tests/cli/files_collector/test_commit_range_documents.py b/tests/cli/files_collector/test_commit_range_documents.py index 501c1811..0b96a0e2 100644 --- a/tests/cli/files_collector/test_commit_range_documents.py +++ b/tests/cli/files_collector/test_commit_range_documents.py @@ -392,15 +392,15 @@ def test_parse_branch_deletion_input(self) -> None: 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_empty_input_returns_none(self) -> None: + """Test that empty input returns None instead of raising.""" + with patch('sys.stdin', StringIO('')): + assert parse_pre_push_input() is None - 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() + def test_parse_whitespace_only_input_returns_none(self) -> None: + """Test that whitespace-only input returns None instead of raising.""" + with patch('sys.stdin', StringIO(' \n\t ')): + assert parse_pre_push_input() is None class TestGetDefaultBranchesForMergeBase: @@ -758,26 +758,23 @@ def test_calculate_range_parsing_push_details(self) -> None: 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.""" + def test_calculate_range_with_new_tag_push_returns_none(self) -> None: + """Test that pushing a new tag returns None (no scanning needed).""" push_details = f'refs/tags/v1.0.0 1234567890abcdef refs/tags/v1.0.0 {consts.EMPTY_COMMIT_SHA}' + result = calculate_pre_push_commit_range(push_details) + assert result is None - 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) + def test_calculate_range_with_tag_deletion_returns_none(self) -> None: + """Test that deleting a tag returns None (no scanning needed).""" + push_details = f'refs/tags/v1.0.0 {consts.EMPTY_COMMIT_SHA} refs/tags/v1.0.0 1234567890abcdef' + result = calculate_pre_push_commit_range(push_details) + assert result is None - 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'] + def test_calculate_range_with_tag_update_returns_none(self) -> None: + """Test that updating a tag returns None (no scanning needed).""" + push_details = 'refs/tags/v1.0.0 1234567890abcdef refs/tags/v1.0.0 0987654321fedcba' + result = calculate_pre_push_commit_range(push_details) + assert result is None class TestPrePushHookIntegration: @@ -805,12 +802,15 @@ def test_simulate_pre_push_hook_input_format(self) -> None: # 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: + if push_input.startswith('refs/tags/'): + # Tag pushes - should return None (no scanning needed) + assert commit_range is None + elif consts.EMPTY_COMMIT_SHA in push_input: + if 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 + # New branch - should return a range or --all assert commit_range is not None else: # Regular update - should return proper range From f89bbe6435becd7bebf017a6fd5bf91450c31b29 Mon Sep 17 00:00:00 2001 From: RoniCycode <142726722+RoniCycode@users.noreply.github.com> Date: Thu, 9 Apr 2026 14:21:30 +0300 Subject: [PATCH 274/347] CM-61986-add-mcp-and-email-enrichment-from-claude-json (#421) --- .../apps/ai_guardrails/scan/claude_config.py | 44 +++++++++++++++ cycode/cli/apps/ai_guardrails/scan/payload.py | 7 ++- .../ai_guardrails/scan/test_payload.py | 54 +++++++++++++++++++ .../ai_guardrails/test_claude_config.py | 54 +++++++++++++++++++ 4 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 cycode/cli/apps/ai_guardrails/scan/claude_config.py create mode 100644 tests/cli/commands/ai_guardrails/test_claude_config.py diff --git a/cycode/cli/apps/ai_guardrails/scan/claude_config.py b/cycode/cli/apps/ai_guardrails/scan/claude_config.py new file mode 100644 index 00000000..cff0a5d7 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/claude_config.py @@ -0,0 +1,44 @@ +"""Reader for ~/.claude.json configuration file. + +Extracts user email from the Claude Code global config file +for use in AI guardrails scan enrichment. +""" + +import json +from pathlib import Path +from typing import Optional + +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Claude Config') + +_CLAUDE_CONFIG_PATH = Path.home() / '.claude.json' + + +def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse ~/.claude.json. + + Args: + config_path: Override path for testing. Defaults to ~/.claude.json. + + Returns: + Parsed dict or None if file is missing or invalid. + """ + path = config_path or _CLAUDE_CONFIG_PATH + if not path.exists(): + logger.debug('Claude config file not found', extra={'path': str(path)}) + return None + try: + content = path.read_text(encoding='utf-8') + return json.loads(content) + except Exception as e: + logger.debug('Failed to load Claude config file', exc_info=e) + return None + + +def get_user_email(config: dict) -> Optional[str]: + """Extract user email from Claude config. + + Reads oauthAccount.emailAddress from the config dict. + """ + return config.get('oauthAccount', {}).get('emailAddress') diff --git a/cycode/cli/apps/ai_guardrails/scan/payload.py b/cycode/cli/apps/ai_guardrails/scan/payload.py index 08e96f9a..9a19970c 100644 --- a/cycode/cli/apps/ai_guardrails/scan/payload.py +++ b/cycode/cli/apps/ai_guardrails/scan/payload.py @@ -7,6 +7,7 @@ from typing import Optional from cycode.cli.apps.ai_guardrails.consts import AIIDEType +from cycode.cli.apps.ai_guardrails.scan.claude_config import get_user_email, load_claude_config from cycode.cli.apps.ai_guardrails.scan.types import ( CLAUDE_CODE_EVENT_MAPPING, CLAUDE_CODE_EVENT_NAMES, @@ -207,11 +208,15 @@ def from_claude_code_payload(cls, payload: dict) -> 'AIHookPayload': # Extract IDE version, model, and generation ID from transcript file ide_version, model, generation_id = _extract_from_claude_transcript(payload.get('transcript_path')) + # Extract user email from ~/.claude.json + claude_config = load_claude_config() + ide_user_email = get_user_email(claude_config) if claude_config else None + 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 + ide_user_email=ide_user_email, model=model, ide_provider=AIIDEType.CLAUDE_CODE.value, ide_version=ide_version, diff --git a/tests/cli/commands/ai_guardrails/scan/test_payload.py b/tests/cli/commands/ai_guardrails/scan/test_payload.py index e17d833d..1ef5fad0 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_payload.py +++ b/tests/cli/commands/ai_guardrails/scan/test_payload.py @@ -322,6 +322,60 @@ def test_from_claude_code_payload_gets_latest_user_uuid(mocker: MockerFixture) - assert unified.generation_id == 'latest-user-uuid' +# Claude Code email extraction tests + + +def test_from_claude_code_payload_extracts_email_from_config(mocker: MockerFixture) -> None: + """Test that ide_user_email is populated from ~/.claude.json.""" + mocker.patch( + 'cycode.cli.apps.ai_guardrails.scan.payload.load_claude_config', + return_value={'oauthAccount': {'emailAddress': 'user@example.com'}}, + ) + + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + assert unified.ide_user_email == 'user@example.com' + + +def test_from_claude_code_payload_email_none_when_config_missing(mocker: MockerFixture) -> None: + """Test that ide_user_email is None when ~/.claude.json is missing.""" + mocker.patch( + 'cycode.cli.apps.ai_guardrails.scan.payload.load_claude_config', + return_value=None, + ) + + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + assert unified.ide_user_email is None + + +def test_from_claude_code_payload_email_none_when_no_oauth(mocker: MockerFixture) -> None: + """Test that ide_user_email is None when oauthAccount is missing from config.""" + mocker.patch( + 'cycode.cli.apps.ai_guardrails.scan.payload.load_claude_config', + return_value={'someOtherKey': 'value'}, + ) + + claude_payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + } + + unified = AIHookPayload.from_claude_code_payload(claude_payload) + assert unified.ide_user_email is None + + # IDE detection tests diff --git a/tests/cli/commands/ai_guardrails/test_claude_config.py b/tests/cli/commands/ai_guardrails/test_claude_config.py new file mode 100644 index 00000000..6bbdbcab --- /dev/null +++ b/tests/cli/commands/ai_guardrails/test_claude_config.py @@ -0,0 +1,54 @@ +"""Tests for Claude Code config file reader.""" + +import json +from pathlib import Path + +from pyfakefs.fake_filesystem import FakeFilesystem + +from cycode.cli.apps.ai_guardrails.scan.claude_config import get_user_email, load_claude_config + + +def test_load_claude_config_valid(fs: FakeFilesystem) -> None: + """Test loading a valid ~/.claude.json file.""" + config = {'oauthAccount': {'emailAddress': 'user@example.com'}} + config_path = Path.home() / '.claude.json' + fs.create_file(config_path, contents=json.dumps(config)) + + result = load_claude_config(config_path) + assert result == config + + +def test_load_claude_config_missing_file(fs: FakeFilesystem) -> None: + """Test loading when ~/.claude.json does not exist.""" + fs.create_dir(Path.home()) + config_path = Path.home() / '.claude.json' + + result = load_claude_config(config_path) + assert result is None + + +def test_load_claude_config_corrupt_file(fs: FakeFilesystem) -> None: + """Test loading when ~/.claude.json contains invalid JSON.""" + config_path = Path.home() / '.claude.json' + fs.create_file(config_path, contents='not valid json {{{') + + result = load_claude_config(config_path) + assert result is None + + +def test_get_user_email_present() -> None: + """Test extracting email when oauthAccount.emailAddress exists.""" + config = {'oauthAccount': {'emailAddress': 'user@example.com'}} + assert get_user_email(config) == 'user@example.com' + + +def test_get_user_email_missing_oauth_account() -> None: + """Test extracting email when oauthAccount key is missing.""" + config = {'someOtherKey': 'value'} + assert get_user_email(config) is None + + +def test_get_user_email_missing_email_address() -> None: + """Test extracting email when oauthAccount exists but emailAddress is missing.""" + config = {'oauthAccount': {'someOtherField': 'value'}} + assert get_user_email(config) is None From e1f5eb7a57652242c41683ac72b1135dfa98e5b1 Mon Sep 17 00:00:00 2001 From: Maor Davidzon <56628808+MaorDavidzon@users.noreply.github.com> Date: Sun, 19 Apr 2026 10:54:46 +0300 Subject: [PATCH 275/347] CM-62578: Expose Cycode API v4 through CLI commands (#435) Co-authored-by: Claude Opus 4.6 (1M context) --- Dockerfile | 2 +- README.md | 64 +++++- cycode/cli/app.py | 23 ++ cycode/cli/apps/api/__init__.py | 69 ++++++ cycode/cli/apps/api/api_command.py | 271 ++++++++++++++++++++++++ cycode/cli/apps/api/openapi_spec.py | 182 ++++++++++++++++ poetry.lock | 2 +- tests/cli/apps/api/__init__.py | 0 tests/cli/apps/api/test_api_command.py | 110 ++++++++++ tests/cli/apps/api/test_openapi_spec.py | 72 +++++++ 10 files changed, 792 insertions(+), 3 deletions(-) create mode 100644 cycode/cli/apps/api/__init__.py create mode 100644 cycode/cli/apps/api/api_command.py create mode 100644 cycode/cli/apps/api/openapi_spec.py create mode 100644 tests/cli/apps/api/__init__.py create mode 100644 tests/cli/apps/api/test_api_command.py create mode 100644 tests/cli/apps/api/test_openapi_spec.py diff --git a/Dockerfile b/Dockerfile index 40d6fad3..574d38ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ FROM base AS builder 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 && \ +RUN apk add --no-cache --virtual .build-deps gcc=14.2.0-r4 libffi-dev=3.4.7-r0 musl-dev=1.2.5-r11 && \ 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 780d947b..806390d0 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,11 @@ This guide walks you through both installation and usage. 2. [Available Options](#available-options) 3. [MCP Tools](#mcp-tools) 4. [Usage Examples](#usage-examples) -5. [Scan Command](#scan-command) +5. [Platform Command](#platform-command-beta) + 1. [Discovering Commands](#discovering-commands) + 2. [Examples](#platform-examples) + 3. [Notes & Limitations](#platform-notes--limitations) +6. [Scan Command](#scan-command) 1. [Running a Scan](#running-a-scan) 1. [Options](#options) 1. [Severity Threshold](#severity-option) @@ -605,6 +609,64 @@ This information can be helpful when: - Debugging transport-specific issues +# Platform Command \[BETA\] + +> [!WARNING] +> The `platform` command is in **beta**. Commands, arguments, and output formats are generated dynamically from the Cycode API spec and may change between releases without notice. Do not rely on them in production automation yet. + +The `cycode platform` command exposes the Cycode platform's read APIs as CLI commands. It groups endpoints by resource (e.g. `projects`, `violations`, `workflows`) and turns each endpoint's parameters into typed CLI arguments and `--option` flags. + +```bash +cycode platform projects list --page-size 50 +cycode platform violations count +cycode platform workflows view +``` + +The OpenAPI spec is fetched from the Cycode API on first use and cached at `~/.cycode/openapi-spec.json` for 24 hours. Unrelated commands (`cycode scan`, `cycode status`, etc.) do not trigger a fetch. + +> [!NOTE] +> You must be authenticated (`cycode auth` or `CYCODE_CLIENT_ID` / `CYCODE_CLIENT_SECRET` environment variables) for `cycode platform` to discover and run commands. Other Cycode CLI commands work without authentication. + +## Discovering Commands + +Because commands are generated from the spec, the source of truth for what's available is `--help`: + +```bash +cycode platform --help # list all resource groups +cycode platform projects --help # list actions on a resource +cycode platform projects list --help # list options/arguments for an action +``` + +## Platform Examples + +```bash +# List projects with pagination +cycode platform projects list --page-size 25 + +# View a single project by ID +cycode platform projects view + +# Count violations across the tenant +cycode platform violations count + +# Filter using query parameters (see `--help` for what each endpoint supports) +cycode platform violations list --severity CRITICAL +``` + +All output is JSON by default — pipe it through `jq` for ad-hoc filtering: + +```bash +cycode platform projects list --page-size 100 | jq '.items[].name' +``` + +## Platform Notes & Limitations + +- **Read-only today.** Only `GET` endpoints are exposed in this beta. +- **Spec-driven.** Adding a new endpoint to the API surfaces it automatically the next time the cache is refreshed. +- **No bundled spec.** The first `cycode platform` invocation after install (or after the 24h cache expires) performs a network fetch. On slow connections this first call may take a few seconds; subsequent calls are near-instant until the cache expires. +- **Override the cache TTL** with `CYCODE_SPEC_CACHE_TTL=`. + + # Scan Command ## Running a Scan diff --git a/cycode/cli/app.py b/cycode/cli/app.py index 0e9f9c7b..103e8b86 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -2,6 +2,7 @@ import sys from typing import Annotated, Optional +import click import typer from typer import rich_utils from typer._completion_classes import completion_init @@ -10,6 +11,7 @@ from cycode import __version__ from cycode.cli.apps import ai_guardrails, ai_remediation, auth, configure, ignore, report, report_import, scan, status +from cycode.cli.apps.api import get_platform_group if sys.version_info >= (3, 10): from cycode.cli.apps import mcp @@ -56,6 +58,27 @@ if sys.version_info >= (3, 10): app.add_typer(mcp.app) +# Register the `platform` command group (dynamically built from the OpenAPI spec). +# The group itself is constructed cheaply at import time; the spec is only fetched +# when the user actually invokes `cycode platform ...`. Unrelated commands like +# `cycode scan` and `cycode status` never trigger a spec fetch. +# +# Typer doesn't support adding native Click groups directly, so we monkey-patch +# typer.main.get_group to inject our `platform` group into the resolved Click group. +# The `app_typer is app` guard ensures we only modify our own app. +_platform_group = get_platform_group() +_original_get_group = typer.main.get_group + + +def _get_group_with_platform(app_typer: typer.Typer) -> click.Group: + group = _original_get_group(app_typer) + if app_typer is app and _platform_group.name not in group.commands: + group.add_command(_platform_group, _platform_group.name) + return group + + +typer.main.get_group = _get_group_with_platform + def check_latest_version_on_close(ctx: typer.Context) -> None: output = ctx.obj.get('output') diff --git a/cycode/cli/apps/api/__init__.py b/cycode/cli/apps/api/__init__.py new file mode 100644 index 00000000..e65f9c6f --- /dev/null +++ b/cycode/cli/apps/api/__init__.py @@ -0,0 +1,69 @@ +"""Cycode platform API CLI commands. + +Dynamically builds CLI command groups from the Cycode API v4 OpenAPI spec. +The spec is fetched lazily — only when the user invokes `cycode platform ...` — +and cached locally for 24 hours. +""" + +from typing import Any, Optional + +import click + +from cycode.logger import get_logger + +logger = get_logger('Platform') + +_PLATFORM_HELP = ( + '[BETA] Access the Cycode platform.\n\n' + 'Commands are generated dynamically from the Cycode API spec and may change ' + 'between releases. The spec is fetched on first use and cached for 24 hours.' +) + + +class PlatformGroup(click.Group): + """Lazy-loading Click group for `cycode platform` subcommands. + + The OpenAPI spec is only fetched when the user actually invokes + `cycode platform ...` (or asks for its help). Unrelated commands like + `cycode scan` or `cycode status` never trigger a spec fetch. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._loaded: bool = False + + def _ensure_loaded(self, ctx: Optional[click.Context]) -> None: + if self._loaded: + return + self._loaded = True # set first to avoid re-entrancy on errors + + client_id = client_secret = None + if ctx is not None: + root = ctx.find_root() + if root.obj: + client_id = root.obj.get('client_id') + client_secret = root.obj.get('client_secret') + + try: + from cycode.cli.apps.api.api_command import build_api_command_groups + + for sub_group, name in build_api_command_groups(client_id, client_secret): + if name not in self.commands: + self.add_command(sub_group, name) + except Exception as e: + logger.debug('Could not load platform commands: %s', e) + # Surface the error to the user only when they're inside `platform` + click.echo(f'Error loading Cycode platform commands: {e}', err=True) + + def list_commands(self, ctx: click.Context) -> list[str]: + self._ensure_loaded(ctx) + return super().list_commands(ctx) + + def get_command(self, ctx: click.Context, cmd_name: str) -> Optional[click.Command]: + self._ensure_loaded(ctx) + return super().get_command(ctx, cmd_name) + + +def get_platform_group() -> click.Group: + """Return the top-level `platform` Click group (lazy-loading).""" + return PlatformGroup(name='platform', help=_PLATFORM_HELP, no_args_is_help=True) diff --git a/cycode/cli/apps/api/api_command.py b/cycode/cli/apps/api/api_command.py new file mode 100644 index 00000000..1926c93c --- /dev/null +++ b/cycode/cli/apps/api/api_command.py @@ -0,0 +1,271 @@ +"""OpenAPI-to-Typer translator: dynamically builds CLI commands from the Cycode API v4 spec.""" + +import json +import re +from typing import Any, Optional + +import click + +from cycode.cli.apps.api.openapi_spec import OpenAPISpecError, get_openapi_spec, parse_spec_commands +from cycode.logger import get_logger + +logger = get_logger('API Command') + +# Map OpenAPI parameter types to Click types +_CLICK_TYPE_MAP: dict[str, click.ParamType] = { + 'string': click.STRING, + 'integer': click.INT, + 'number': click.FLOAT, + 'boolean': click.BOOL, +} + + +def _normalize_tag(tag: str) -> str: + """Normalize an OpenAPI tag to a CLI-friendly command name. + + 'Scan Statistics' -> 'scan-statistics' + 'CLI scan statistics' -> 'cli-scan-statistics' + """ + return re.sub(r'[^a-z0-9]+', '-', tag.lower()).strip('-') + + +def _find_common_prefix(paths: list[str]) -> str: + """Find the longest common path prefix shared by all paths.""" + if not paths: + return '' + if len(paths) == 1: + # For single-path tags, use the parent directory as prefix + return '/'.join(paths[0].split('/')[:-1]) + + common = paths[0] + for p in paths[1:]: + while not p.startswith(common + '/') and common != p: + common = '/'.join(common.split('/')[:-1]) + return common + + +def _path_to_command_name(path: str, common_prefix: str, has_path_params: bool) -> str: + """Derive a CLI command name from an API path relative to the tag's common prefix. + + Rules: + 1. Strip the common prefix shared by all endpoints in the tag + 2. Remove path parameter segments ({id}) + 3. If nothing remains: 'list' (no path params) or 'view' (has path params) + 4. Otherwise: use remaining segments joined with hyphens + + Examples: + /v4/projects (prefix=/v4/projects) -> list + /v4/projects/{id} (prefix=/v4/projects) -> view + /v4/projects/assets (prefix=/v4/projects) -> assets + /v4/violations/count (prefix=/v4/violations) -> count + """ + # Strip common prefix + relative = path[len(common_prefix) :] if path.startswith(common_prefix) else path + relative = relative.strip('/') + + # Remove path parameter segments and empty parts + parts = [p for p in relative.split('/') if p and not p.startswith('{')] + + if not parts: + return 'view' if has_path_params else 'list' + + # Join remaining segments with hyphens, normalize to kebab-case + return re.sub(r'[^a-z0-9]+', '-', '-'.join(parts).lower()).strip('-') + + +def _param_to_option_name(name: str) -> str: + """Convert an OpenAPI parameter name to a CLI option name. + + 'page_size' -> '--page-size' + 'pageSize' -> '--page-size' + 'filter.status' -> '--filter-status' + """ + s = re.sub(r'([a-z])([A-Z])', r'\1-\2', name) + # Replace any non-alphanumeric characters with hyphens + s = re.sub(r'[^a-z0-9]+', '-', s.lower()).strip('-') + return f'--{s}' + + +def _make_api_request( + endpoint_path: str, + method: str, + path_params: dict[str, str], + query_params: dict[str, Any], + client_id: Optional[str] = None, + client_secret: Optional[str] = None, +) -> dict: + """Execute an API request using the CLI's standard auth client.""" + from urllib.parse import quote + + from cycode.cli.apps.api.openapi_spec import resolve_credentials + from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient + + cid, csecret = resolve_credentials(client_id, client_secret) + client = CycodeTokenBasedClient(cid, csecret) + + # Substitute path parameters (URL-encoded to prevent path traversal) + url_path = endpoint_path + for param_name, param_value in path_params.items(): + url_path = url_path.replace(f'{{{param_name}}}', quote(str(param_value), safe='')) + + filtered_query = {k: v for k, v in query_params.items() if v is not None} + + response = client.get(url_path.lstrip('/'), params=filtered_query) + return response.json() + + +def build_api_command_groups( + client_id: Optional[str] = None, + client_secret: Optional[str] = None, +) -> list[tuple[click.Group, str]]: + """Build Click command groups from the OpenAPI spec. + + Returns a list of (click_group, command_name) tuples. + """ + try: + spec = get_openapi_spec(client_id, client_secret) + except OpenAPISpecError as e: + logger.warning('Could not load OpenAPI spec: %s', e) + return [] + + groups = parse_spec_commands(spec) + result = [] + + for tag, endpoints in groups.items(): + tag_name = _normalize_tag(tag) + + group = click.Group(name=tag_name, help=f'[BETA] {tag}') + + # Compute common prefix from all GET (non-deprecated) endpoint paths in this tag + get_endpoints = [ep for ep in endpoints if ep['method'] == 'get' and not ep.get('deprecated')] + if not get_endpoints: + continue + + clean_paths = [re.sub(r'/\{[^}]+\}', '', ep['path']) for ep in get_endpoints] + common_prefix = _find_common_prefix(clean_paths) + + used_names: dict[str, int] = {} + + for endpoint in get_endpoints: + has_path_params = bool(endpoint['path_params']) + cmd_name = _path_to_command_name(endpoint['path'], common_prefix, has_path_params) + + # Fix redundancy: if command name matches the tag name, use list/view + # e.g. "cycode groups groups" -> "cycode groups list" + if cmd_name == tag_name: + cmd_name = 'view' if has_path_params else 'list' + + # Handle duplicate names (e.g. deprecated + new endpoint for same resource) + if cmd_name in used_names: + used_names[cmd_name] += 1 + cmd_name = f'{cmd_name}-v{used_names[cmd_name]}' + else: + used_names[cmd_name] = 1 + + cmd = _build_endpoint_command(cmd_name, endpoint) + group.add_command(cmd, cmd_name) + + result.append((group, tag_name)) + + return result + + +def _build_click_params(endpoint: dict) -> list[click.Parameter]: + """Build Click parameters from OpenAPI endpoint definition.""" + params: list[click.Parameter] = [] + + # Path parameters -> required arguments + for p in endpoint['path_params']: + param_type = _CLICK_TYPE_MAP.get(p.get('schema', {}).get('type', 'string'), click.STRING) + params.append( + click.Argument( + [p['name'].replace('-', '_')], + type=param_type, + required=True, + ) + ) + + # Query parameters -> --option flags + for p in endpoint['query_params']: + param_type = _CLICK_TYPE_MAP.get(p.get('schema', {}).get('type', 'string'), click.STRING) + option_name = _param_to_option_name(p['name']) + required = p.get('required', False) + default = p.get('schema', {}).get('default') + + schema = p.get('schema', {}) + if 'enum' in schema: + param_type = click.Choice(schema['enum']) + + params.append( + click.Option( + [option_name], + type=param_type, + required=required, + default=default, + help=p.get('description', ''), + show_default=default is not None, + ) + ) + + return params + + +def _build_endpoint_command(cmd_name: str, endpoint: dict) -> click.Command: + """Build a Click command for an API endpoint. + + Path parameters become required CLI arguments. + Query parameters become --option flags with proper types. + """ + ep_path = endpoint['path'] + ep_method = endpoint['method'] + ep_path_params = list(endpoint['path_params']) + ep_query_params = list(endpoint['query_params']) + ep_description = endpoint['description'] or endpoint['summary'] + + # Build a mapping from Click's normalized kwarg name to original OpenAPI param name + _path_param_map = {p['name'].replace('-', '_').lower(): p['name'] for p in ep_path_params} + _query_param_map = {re.sub(r'[^a-z0-9]+', '_', p['name'].lower()).strip('_'): p['name'] for p in ep_query_params} + + def _callback(**kwargs: Any) -> None: + ctx = click.get_current_context() + + # Extract path param values using the mapping + path_values = {} + for kwarg_key, original_name in _path_param_map.items(): + if kwarg_key in kwargs and kwargs[kwarg_key] is not None: + path_values[original_name] = kwargs[kwarg_key] + + # Extract query param values (skip None) + query_values = {} + for kwarg_key, original_name in _query_param_map.items(): + value = kwargs.get(kwarg_key) + if value is not None: + query_values[original_name] = value + + # Get auth from root context (set by app_callback) + root_ctx = ctx.find_root() + client_id = root_ctx.obj.get('client_id') if root_ctx.obj else None + client_secret = root_ctx.obj.get('client_secret') if root_ctx.obj else None + + try: + result = _make_api_request( + ep_path, + ep_method, + path_values, + query_values, + client_id=client_id, + client_secret=client_secret, + ) + except Exception as e: + click.echo(f'Error: {e}', err=True) + raise click.Abort from e + + click.echo(json.dumps(result, indent=2)) + + return click.Command( + name=cmd_name, + callback=_callback, + help=ep_description, + short_help=endpoint['summary'], + params=_build_click_params(endpoint), + ) diff --git a/cycode/cli/apps/api/openapi_spec.py b/cycode/cli/apps/api/openapi_spec.py new file mode 100644 index 00000000..74ffdb69 --- /dev/null +++ b/cycode/cli/apps/api/openapi_spec.py @@ -0,0 +1,182 @@ +"""OpenAPI spec manager: fetch, cache, and parse the Cycode API v4 spec.""" + +import json +import os +import time +from pathlib import Path +from typing import Optional + +from cycode.cli.consts import CYCODE_CONFIGURATION_DIRECTORY +from cycode.cli.user_settings.credentials_manager import CredentialsManager +from cycode.cyclient import config as cyclient_config +from cycode.logger import get_logger + +logger = get_logger('OpenAPI Spec') + +_CACHE_DIR = Path.home() / CYCODE_CONFIGURATION_DIRECTORY +_CACHE_FILE = _CACHE_DIR / 'openapi-spec.json' +_CACHE_TTL_SECONDS = int(os.getenv('CYCODE_SPEC_CACHE_TTL', str(24 * 60 * 60))) # 24h default + +_OPENAPI_SPEC_PATH = '/v4/api-docs/cycode-api-swagger.json' + + +def get_openapi_spec(client_id: Optional[str] = None, client_secret: Optional[str] = None) -> dict: + """Get the OpenAPI spec, using cache if fresh, otherwise fetching from API. + + The spec is only fetched when the user actually invokes `cycode platform ...`. + Fetch uses the HTTP client's default timeout; on a slow connection the first + invocation will block accordingly. Once cached, subsequent invocations within + the TTL are near-instant. + + Args: + client_id: Optional client ID override (from CLI flags). + client_secret: Optional client secret override (from CLI flags). + + Returns: + Parsed OpenAPI specification dictionary. + + Raises: + OpenAPISpecError: If spec cannot be loaded from cache or API. + """ + cached = _load_cached_spec() + if cached is not None: + return cached + + return _fetch_and_cache_spec(client_id, client_secret) + + +def _load_cached_spec() -> Optional[dict]: + """Load spec from local cache if it exists and is fresh.""" + if not _CACHE_FILE.exists(): + return None + + try: + mtime = _CACHE_FILE.stat().st_mtime + if time.time() - mtime > _CACHE_TTL_SECONDS: + logger.debug('Cached OpenAPI spec is stale (age > %ds)', _CACHE_TTL_SECONDS) + return None + + spec = json.loads(_CACHE_FILE.read_text(encoding='utf-8')) + logger.debug('Using cached OpenAPI spec from %s', _CACHE_FILE) + return spec + except Exception as e: + logger.warning('Failed to load cached OpenAPI spec: %s', e) + return None + + +def resolve_credentials(client_id: Optional[str] = None, client_secret: Optional[str] = None) -> tuple[str, str]: + """Resolve credentials from args or the CLI's standard credential chain.""" + if not client_id or not client_secret: + credentials_manager = CredentialsManager() + cred_id, cred_secret = credentials_manager.get_credentials() + client_id = client_id or cred_id + client_secret = client_secret or cred_secret + + if not client_id or not client_secret: + raise OpenAPISpecError( + 'Cycode credentials not found. Run `cycode auth` first, ' + 'or set CYCODE_CLIENT_ID and CYCODE_CLIENT_SECRET environment variables.' + ) + + return client_id, client_secret + + +def _fetch_and_cache_spec(client_id: Optional[str] = None, client_secret: Optional[str] = None) -> dict: + """Fetch OpenAPI spec from API and cache to disk. + + Uses CycodeTokenBasedClient for auth and retries. The spec is served from the app URL, + so we create a client with app_url as base instead of the default api_url. + """ + from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient + + cid, csecret = resolve_credentials(client_id, client_secret) + + # The spec is served from app.cycode.com, but token refresh POSTs to api.cycode.com. + # Ensure the token is fresh BEFORE overriding the base URL so that refresh + # targets the correct host. + client = CycodeTokenBasedClient(cid, csecret) + client.get_access_token() + client.api_url = cyclient_config.cycode_app_url + + spec_path = _OPENAPI_SPEC_PATH.lstrip('/') + logger.info('Fetching OpenAPI spec from %s/%s', cyclient_config.cycode_app_url, spec_path) + + try: + response = client.get(spec_path) + spec = response.json() + except Exception as e: + raise OpenAPISpecError( + f'Failed to fetch OpenAPI spec. Check your authentication and network connectivity. Error: {e}' + ) from e + + if not isinstance(spec, dict) or 'paths' not in spec: + raise OpenAPISpecError('Response does not look like a valid OpenAPI spec (missing "paths" key).') + + # Override server URL with API URL (supports on-premise installations) + spec['servers'] = [{'url': cyclient_config.cycode_api_url}] + + # Cache to disk + _cache_spec(spec) + + return spec + + +def _cache_spec(spec: dict) -> None: + """Write spec to local cache file atomically (write to temp file, then rename).""" + try: + _CACHE_DIR.mkdir(parents=True, exist_ok=True) + tmp_file = _CACHE_FILE.with_suffix('.json.tmp') + tmp_file.write_text(json.dumps(spec), encoding='utf-8') + tmp_file.replace(_CACHE_FILE) # atomic on POSIX and Windows + logger.debug('Cached OpenAPI spec to %s', _CACHE_FILE) + except Exception as e: + logger.warning('Failed to cache OpenAPI spec: %s', e) + + +def parse_spec_commands(spec: dict) -> dict[str, list[dict]]: + """Parse OpenAPI spec into resource groups with their endpoints. + + Groups endpoints by their first tag, returning a dict of: + {tag_name: [endpoint_info, ...]} + + Each endpoint_info contains: + - path: API path (e.g., '/v4/projects/{projectId}') + - method: HTTP method (e.g., 'get') + - summary: Human-readable summary + - description: Detailed description + - operation_id: Unique operation ID + - path_params: List of path parameter definitions + - query_params: List of query parameter definitions + """ + groups: dict[str, list[dict]] = {} + + for path, methods in spec.get('paths', {}).items(): + for method, details in methods.items(): + tags = details.get('tags', ['other']) + tag = tags[0] if tags else 'other' + + # Separate path and query parameters + parameters = details.get('parameters', []) + path_params = [p for p in parameters if p.get('in') == 'path'] + query_params = [p for p in parameters if p.get('in') == 'query'] + + endpoint_info = { + 'path': path, + 'method': method, + 'summary': details.get('summary', ''), + 'description': details.get('description', ''), + 'operation_id': details.get('operationId', ''), + 'path_params': path_params, + 'query_params': query_params, + 'deprecated': details.get('deprecated', False), + } + + if tag not in groups: + groups[tag] = [] + groups[tag].append(endpoint_info) + + return groups + + +class OpenAPISpecError(Exception): + """Raised when the OpenAPI spec cannot be loaded.""" diff --git a/poetry.lock b/poetry.lock index 3b793f4a..582ee5a5 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.3 and should not be changed by hand. [[package]] name = "altgraph" diff --git a/tests/cli/apps/api/__init__.py b/tests/cli/apps/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/apps/api/test_api_command.py b/tests/cli/apps/api/test_api_command.py new file mode 100644 index 00000000..0e981f33 --- /dev/null +++ b/tests/cli/apps/api/test_api_command.py @@ -0,0 +1,110 @@ +"""Tests for the OpenAPI-to-Click translator.""" + +from cycode.cli.apps.api.api_command import ( + _find_common_prefix, + _normalize_tag, + _param_to_option_name, + _path_to_command_name, +) + +# --- _normalize_tag --- + + +def test_normalize_tag_simple() -> None: + assert _normalize_tag('Projects') == 'projects' + + +def test_normalize_tag_multi_word() -> None: + assert _normalize_tag('Scan Statistics') == 'scan-statistics' + + +def test_normalize_tag_with_special_chars() -> None: + assert _normalize_tag('CLI scan statistics') == 'cli-scan-statistics' + + +def test_normalize_tag_strips_leading_trailing_separators() -> None: + assert _normalize_tag(' Projects ') == 'projects' + + +# --- _param_to_option_name --- + + +def test_param_to_option_name_snake_case() -> None: + assert _param_to_option_name('page_size') == '--page-size' + + +def test_param_to_option_name_camel_case() -> None: + assert _param_to_option_name('pageSize') == '--page-size' + + +def test_param_to_option_name_with_dot() -> None: + assert _param_to_option_name('filter.status') == '--filter-status' + + +def test_param_to_option_name_already_kebab() -> None: + assert _param_to_option_name('page-size') == '--page-size' + + +# --- _find_common_prefix --- + + +def test_find_common_prefix_empty() -> None: + assert _find_common_prefix([]) == '' + + +def test_find_common_prefix_single_path() -> None: + # Single path: use parent directory as prefix + assert _find_common_prefix(['/v4/projects']) == '/v4' + + +def test_find_common_prefix_two_paths_with_common_parent() -> None: + assert _find_common_prefix(['/v4/projects', '/v4/projects/assets']) == '/v4/projects' + + +def test_find_common_prefix_two_paths_with_grandparent() -> None: + assert _find_common_prefix(['/v4/projects', '/v4/members']) == '/v4' + + +def test_find_common_prefix_identical_paths() -> None: + assert _find_common_prefix(['/v4/projects', '/v4/projects']) == '/v4/projects' + + +# --- _path_to_command_name --- + + +def test_path_to_command_name_collection() -> None: + # /v4/projects with prefix /v4/projects -> nothing left -> 'list' + assert _path_to_command_name('/v4/projects', '/v4/projects', has_path_params=False) == 'list' + + +def test_path_to_command_name_single_resource() -> None: + # /v4/projects/{id} with prefix /v4/projects -> only path param left -> 'view' + assert _path_to_command_name('/v4/projects/{projectId}', '/v4/projects', has_path_params=True) == 'view' + + +def test_path_to_command_name_sub_resource() -> None: + # /v4/projects/assets with prefix /v4/projects -> 'assets' + assert _path_to_command_name('/v4/projects/assets', '/v4/projects', has_path_params=False) == 'assets' + + +def test_path_to_command_name_sub_resource_count() -> None: + # /v4/violations/count with prefix /v4/violations -> 'count' + assert _path_to_command_name('/v4/violations/count', '/v4/violations', has_path_params=False) == 'count' + + +def test_path_to_command_name_multi_segment() -> None: + # /v4/projects/collisions/count with prefix /v4/projects -> 'collisions-count' + assert ( + _path_to_command_name('/v4/projects/collisions/count', '/v4/projects', has_path_params=False) + == 'collisions-count' + ) + + +def test_path_to_command_name_with_path_param_in_middle() -> None: + # /v4/workflows/{id}/jobs with prefix /v4/workflows -> 'jobs' (path param stripped) + assert _path_to_command_name('/v4/workflows/{workflowId}/jobs', '/v4/workflows', has_path_params=True) == 'jobs' + + +def test_path_to_command_name_kebab_case_normalization() -> None: + # Path with underscores or special chars -> kebab-case + assert _path_to_command_name('/v4/brokers/broker_metrics', '/v4/brokers', has_path_params=False) == 'broker-metrics' diff --git a/tests/cli/apps/api/test_openapi_spec.py b/tests/cli/apps/api/test_openapi_spec.py new file mode 100644 index 00000000..1b863d89 --- /dev/null +++ b/tests/cli/apps/api/test_openapi_spec.py @@ -0,0 +1,72 @@ +"""Tests for the OpenAPI spec parser.""" + +from cycode.cli.apps.api.openapi_spec import parse_spec_commands + + +def test_parse_spec_commands_groups_by_tag() -> None: + spec = { + 'paths': { + '/v4/projects': { + 'get': {'tags': ['Projects'], 'summary': 'Get projects'}, + }, + '/v4/violations': { + 'get': {'tags': ['Violations'], 'summary': 'Get violations'}, + }, + } + } + groups = parse_spec_commands(spec) + assert set(groups.keys()) == {'Projects', 'Violations'} + + +def test_parse_spec_commands_extracts_path_params() -> None: + spec = { + 'paths': { + '/v4/projects/{projectId}': { + 'get': { + 'tags': ['Projects'], + 'parameters': [ + {'name': 'projectId', 'in': 'path', 'required': True}, + {'name': 'page_size', 'in': 'query', 'required': False}, + ], + }, + }, + } + } + groups = parse_spec_commands(spec) + ep = groups['Projects'][0] + assert len(ep['path_params']) == 1 + assert ep['path_params'][0]['name'] == 'projectId' + assert len(ep['query_params']) == 1 + assert ep['query_params'][0]['name'] == 'page_size' + + +def test_parse_spec_commands_captures_deprecated_flag() -> None: + spec = { + 'paths': { + '/v4/old': { + 'get': {'tags': ['T'], 'summary': 'old', 'deprecated': True}, + }, + '/v4/new': { + 'get': {'tags': ['T'], 'summary': 'new'}, + }, + } + } + groups = parse_spec_commands(spec) + by_path = {ep['path']: ep for ep in groups['T']} + assert by_path['/v4/old']['deprecated'] is True + assert by_path['/v4/new']['deprecated'] is False + + +def test_parse_spec_commands_no_tags_uses_other() -> None: + spec = { + 'paths': { + '/v4/foo': {'get': {}}, + } + } + groups = parse_spec_commands(spec) + assert 'other' in groups + + +def test_parse_spec_commands_empty_spec() -> None: + assert parse_spec_commands({}) == {} + assert parse_spec_commands({'paths': {}}) == {} From 5405656e5f55f81135ffb32cfa224f98e04d61fd Mon Sep 17 00:00:00 2001 From: RoniCycode <142726722+RoniCycode@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:17:24 +0300 Subject: [PATCH 276/347] CM-62381-add-session-start-hook (#434) --- cycode/cli/apps/ai_guardrails/__init__.py | 23 +- cycode/cli/apps/ai_guardrails/consts.py | 6 +- .../apps/ai_guardrails/ensure_auth_command.py | 21 - .../apps/ai_guardrails/scan/claude_config.py | 115 +++++ .../apps/ai_guardrails/scan/cursor_config.py | 36 ++ .../cli/apps/ai_guardrails/scan/handlers.py | 3 - cycode/cli/apps/ai_guardrails/scan/payload.py | 6 +- .../ai_guardrails/session_start_command.py | 150 +++++++ cycode/cyclient/ai_security_manager_client.py | 18 + .../ai_guardrails/scan/test_handlers.py | 2 + .../ai_guardrails/test_hooks_manager.py | 12 +- .../test_session_start_command.py | 392 ++++++++++++++++++ 12 files changed, 738 insertions(+), 46 deletions(-) delete mode 100644 cycode/cli/apps/ai_guardrails/ensure_auth_command.py create mode 100644 cycode/cli/apps/ai_guardrails/scan/cursor_config.py create mode 100644 cycode/cli/apps/ai_guardrails/session_start_command.py create mode 100644 tests/cli/commands/ai_guardrails/test_session_start_command.py diff --git a/cycode/cli/apps/ai_guardrails/__init__.py b/cycode/cli/apps/ai_guardrails/__init__.py index 11267624..1443008d 100644 --- a/cycode/cli/apps/ai_guardrails/__init__.py +++ b/cycode/cli/apps/ai_guardrails/__init__.py @@ -1,23 +1,24 @@ import typer -from cycode.cli.apps.ai_guardrails.ensure_auth_command import ensure_auth_command -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 +from cycode.cli.apps.ai_guardrails.install_command import install_command as _install_command +from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command as _scan_command +from cycode.cli.apps.ai_guardrails.session_start_command import session_start_command as _session_start_command +from cycode.cli.apps.ai_guardrails.status_command import status_command as _status_command +from cycode.cli.apps.ai_guardrails.uninstall_command import uninstall_command as _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='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 + _uninstall_command ) -app.command(hidden=True, name='status', short_help='Show AI guardrails hook installation status.')(status_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) -app.command(hidden=True, name='ensure-auth', short_help='Ensure authentication, triggering auth if needed.')( - ensure_auth_command +)(_scan_command) +app.command(hidden=True, name='session-start', short_help='Handle session start: auth, conversation, session context.')( + _session_start_command ) +app.command(hidden=True, name='ensure-auth', short_help='[Deprecated] Alias for session-start.')(_session_start_command) diff --git a/cycode/cli/apps/ai_guardrails/consts.py b/cycode/cli/apps/ai_guardrails/consts.py index 81539b30..837096c8 100644 --- a/cycode/cli/apps/ai_guardrails/consts.py +++ b/cycode/cli/apps/ai_guardrails/consts.py @@ -84,7 +84,7 @@ def _get_claude_code_hooks_dir() -> Path: # Command used in hooks CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan' -CYCODE_ENSURE_AUTH_COMMAND = 'cycode ai-guardrails ensure-auth' +CYCODE_SESSION_START_COMMAND = 'cycode ai-guardrails session-start' def _get_cursor_hooks_config(async_mode: bool = False) -> dict: @@ -92,7 +92,7 @@ def _get_cursor_hooks_config(async_mode: bool = False) -> dict: config = IDE_CONFIGS[AIIDEType.CURSOR] command = f'{CYCODE_SCAN_PROMPT_COMMAND} &' if async_mode else CYCODE_SCAN_PROMPT_COMMAND hooks = {event: [{'command': command}] for event in config.hook_events} - hooks['sessionStart'] = [{'command': CYCODE_ENSURE_AUTH_COMMAND}] + hooks['sessionStart'] = [{'command': f'{CYCODE_SESSION_START_COMMAND} --ide cursor'}] return { 'version': 1, @@ -119,7 +119,7 @@ def _get_claude_code_hooks_config(async_mode: bool = False) -> dict: 'SessionStart': [ { 'matcher': 'startup', - 'hooks': [{'type': 'command', 'command': CYCODE_ENSURE_AUTH_COMMAND}], + 'hooks': [{'type': 'command', 'command': f'{CYCODE_SESSION_START_COMMAND} --ide claude-code'}], } ], 'UserPromptSubmit': [ diff --git a/cycode/cli/apps/ai_guardrails/ensure_auth_command.py b/cycode/cli/apps/ai_guardrails/ensure_auth_command.py deleted file mode 100644 index 78b8bf83..00000000 --- a/cycode/cli/apps/ai_guardrails/ensure_auth_command.py +++ /dev/null @@ -1,21 +0,0 @@ -import typer - -from cycode.cli.apps.auth.auth_common import get_authorization_info -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 - - -def ensure_auth_command(ctx: typer.Context) -> None: - """Ensure the user is authenticated, triggering authentication if needed.""" - auth_info = get_authorization_info(ctx) - if auth_info is not None: - logger.debug('Already authenticated') - return - - logger.debug('Not authenticated, starting authentication') - try: - auth_manager = AuthManager() - auth_manager.authenticate() - except Exception as err: - handle_auth_exception(ctx, err) diff --git a/cycode/cli/apps/ai_guardrails/scan/claude_config.py b/cycode/cli/apps/ai_guardrails/scan/claude_config.py index cff0a5d7..4b547427 100644 --- a/cycode/cli/apps/ai_guardrails/scan/claude_config.py +++ b/cycode/cli/apps/ai_guardrails/scan/claude_config.py @@ -13,6 +13,7 @@ logger = get_logger('AI Guardrails Claude Config') _CLAUDE_CONFIG_PATH = Path.home() / '.claude.json' +_CLAUDE_SETTINGS_PATH = Path.home() / '.claude' / 'settings.json' def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]: @@ -42,3 +43,117 @@ def get_user_email(config: dict) -> Optional[str]: Reads oauthAccount.emailAddress from the config dict. """ return config.get('oauthAccount', {}).get('emailAddress') + + +def get_mcp_servers(config: dict) -> Optional[dict]: + """Extract MCP servers from Claude config. + + Reads mcpServers from the config dict. + """ + return config.get('mcpServers') + + +def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse ~/.claude/settings.json. + + Args: + settings_path: Override path for testing. Defaults to ~/.claude/settings.json. + + Returns: + Parsed dict or None if file is missing or invalid. + """ + path = settings_path or _CLAUDE_SETTINGS_PATH + if not path.exists(): + logger.debug('Claude settings file not found', extra={'path': str(path)}) + return None + try: + content = path.read_text(encoding='utf-8') + return json.loads(content) + except Exception as e: + logger.debug('Failed to load Claude settings file', exc_info=e) + return None + + +def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]: + """ + Resolve filesystem path for a directory-type marketplace. + """ + source = marketplace.get('source', {}) + if source.get('source') != 'directory': + return None + raw = source.get('path') + if not raw: + return None + path = Path(raw) + return path if path.is_dir() else None + + +def _load_plugin_json_file(plugin_path: Path, relative_path: str) -> Optional[dict]: + """Load and parse a JSON file inside a plugin directory. + + Returns None if the file is missing, unreadable, or has invalid JSON. + """ + target = plugin_path / relative_path + if not target.exists(): + return None + try: + return json.loads(target.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load plugin file', extra={'path': str(target)}, exc_info=e) + return None + + +def resolve_plugins(settings: dict) -> tuple[dict, dict]: + """Resolve enabled plugins to their MCP servers and metadata. + + Walks enabledPlugins from claude settings, resolves each plugin's 'marketplace' directory + via the 'extraKnownMarketplaces' field, and reads: + - /.mcp.json for MCP servers (merged into a flat dict) + - /.claude-plugin/plugin.json for metadata (name, version, description) + + Args: + settings: Parsed ~/.claude/settings.json dict. + + Returns: + Tuple of (merged_mcp_servers, enriched_plugins): + - merged_mcp_servers: {server_name: server_config, ...} + - enriched_plugins: {plugin_key: {"enabled": True, "name": ..., ...}, ...} + """ + enabled = settings.get('enabledPlugins') or {} + marketplaces = settings.get('extraKnownMarketplaces') or {} + merged_mcp: dict = {} + enriched: dict = {} + + for plugin_key, is_enabled in enabled.items(): + if not is_enabled: + continue + + entry: dict = {'enabled': True} + enriched[plugin_key] = entry + + if '@' not in plugin_key: + continue + + _plugin_name, marketplace_name = plugin_key.split('@', 1) + marketplace = marketplaces.get(marketplace_name) + if not marketplace: + continue + + plugin_path = _resolve_marketplace_path(marketplace) + if plugin_path is None: + continue + + metadata = _load_plugin_json_file(plugin_path, '.claude-plugin/plugin.json') or {} + for field in ('name', 'version', 'description'): + if field in metadata: + entry[field] = metadata[field] + + mcp_config = _load_plugin_json_file(plugin_path, '.mcp.json') or {} + plugin_server_names = [] + for server_name, server_cfg in (mcp_config.get('mcpServers') or {}).items(): + merged_mcp[server_name] = server_cfg + plugin_server_names.append(server_name) + if plugin_server_names: + entry['mcp_server_names'] = plugin_server_names + + return merged_mcp, enriched diff --git a/cycode/cli/apps/ai_guardrails/scan/cursor_config.py b/cycode/cli/apps/ai_guardrails/scan/cursor_config.py new file mode 100644 index 00000000..9a174a7a --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/scan/cursor_config.py @@ -0,0 +1,36 @@ +"""Reader for ~/.cursor/mcp.json configuration file. + +Extracts MCP server definitions from the Cursor global config file +for use in AI guardrails session-context reporting. +""" + +import json +from pathlib import Path +from typing import Optional + +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Cursor Config') + +_CURSOR_MCP_CONFIG_PATH = Path.home() / '.cursor' / 'mcp.json' + + +def load_cursor_config(config_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse ~/.cursor/mcp.json. + + Args: + config_path: Override path for testing. Defaults to ~/.cursor/mcp.json. + + Returns: + Parsed dict or None if file is missing or invalid. + """ + path = config_path or _CURSOR_MCP_CONFIG_PATH + if not path.exists(): + logger.debug('Cursor MCP config file not found', extra={'path': str(path)}) + return None + try: + content = path.read_text(encoding='utf-8') + return json.loads(content) + except Exception as e: + logger.debug('Failed to load Cursor MCP config file', exc_info=e) + return None diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 99fa29c8..fa0bddee 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -42,7 +42,6 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli 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() @@ -100,7 +99,6 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: 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() @@ -203,7 +201,6 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli 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() diff --git a/cycode/cli/apps/ai_guardrails/scan/payload.py b/cycode/cli/apps/ai_guardrails/scan/payload.py index 9a19970c..ada40a3c 100644 --- a/cycode/cli/apps/ai_guardrails/scan/payload.py +++ b/cycode/cli/apps/ai_guardrails/scan/payload.py @@ -71,7 +71,7 @@ def _extract_generation_id(entry: dict) -> Optional[str]: return None -def _extract_from_claude_transcript( +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. @@ -123,7 +123,7 @@ 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') + event_name: Optional[str] = None # Canonical event type (e.g., 'prompt', 'file_read', 'mcp_execution') conversation_id: Optional[str] = None generation_id: Optional[str] = None @@ -206,7 +206,7 @@ def from_claude_code_payload(cls, payload: dict) -> 'AIHookPayload': 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')) + ide_version, model, generation_id = extract_from_claude_transcript(payload.get('transcript_path')) # Extract user email from ~/.claude.json claude_config = load_claude_config() diff --git a/cycode/cli/apps/ai_guardrails/session_start_command.py b/cycode/cli/apps/ai_guardrails/session_start_command.py new file mode 100644 index 00000000..a33dc439 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/session_start_command.py @@ -0,0 +1,150 @@ +import sys +from typing import TYPE_CHECKING, Annotated + +import typer + +from cycode.cli.apps.ai_guardrails.consts import AIIDEType +from cycode.cli.apps.ai_guardrails.scan.claude_config import ( + get_mcp_servers, + get_user_email, + load_claude_config, + load_claude_settings, + resolve_plugins, +) +from cycode.cli.apps.ai_guardrails.scan.cursor_config import load_cursor_config +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload, extract_from_claude_transcript +from cycode.cli.apps.ai_guardrails.scan.utils import safe_json_parse +from cycode.cli.apps.auth.auth_common import get_authorization_info +from cycode.cli.apps.auth.auth_manager import AuthManager +from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception +from cycode.cli.utils.get_api_client import get_ai_security_manager_client +from cycode.logger import get_logger + +if TYPE_CHECKING: + from cycode.cyclient.ai_security_manager_client import AISecurityManagerClient + +logger = get_logger('AI Guardrails') + + +def _build_session_payload(payload: dict, ide: str) -> AIHookPayload: + """Build an AIHookPayload from a session-start stdin payload.""" + if ide == AIIDEType.CLAUDE_CODE: + claude_config = load_claude_config() + ide_user_email = get_user_email(claude_config) if claude_config else None + ide_version, _, _ = extract_from_claude_transcript(payload.get('transcript_path')) + + return AIHookPayload( + conversation_id=payload.get('session_id'), + ide_user_email=ide_user_email, + model=payload.get('model'), + ide_provider=AIIDEType.CLAUDE_CODE.value, + ide_version=ide_version, + ) + + # Cursor + return AIHookPayload( + conversation_id=payload.get('conversation_id'), + ide_user_email=payload.get('user_email'), + model=payload.get('model'), + ide_provider=AIIDEType.CURSOR.value, + ide_version=payload.get('cursor_version'), + ) + + +def _get_claude_code_session_context() -> tuple[dict, dict]: + """Return (mcp_servers, enabled_plugins) for Claude Code. + + Merges MCP servers from ~/.claude.json (user-configured) with those contributed + by enabled plugins. Plugin metadata (name, version, description) is included in + the enabled_plugins dict when resolvable. + """ + config = load_claude_config() + mcp_servers = dict(get_mcp_servers(config) or {}) if config else {} + + settings = load_claude_settings() + if settings: + plugin_mcp, enriched_plugins = resolve_plugins(settings) + mcp_servers.update(plugin_mcp) + else: + enriched_plugins = {} + + return mcp_servers, enriched_plugins + + +def _get_cursor_session_context() -> tuple[dict, dict]: + """Return (mcp_servers, enabled_plugins) for Cursor. Cursor has no plugin system.""" + config = load_cursor_config() + mcp_servers = dict(get_mcp_servers(config) or {}) if config else {} + return mcp_servers, {} + + +def _report_session_context(ai_client: 'AISecurityManagerClient', ide: str) -> None: + """Report IDE session context to the AI security manager. Never raises.""" + try: + if ide == AIIDEType.CLAUDE_CODE: + mcp_servers, enabled_plugins = _get_claude_code_session_context() + elif ide == AIIDEType.CURSOR: + mcp_servers, enabled_plugins = _get_cursor_session_context() + else: + return + + if not mcp_servers and not enabled_plugins: + return + ai_client.report_session_context(mcp_servers=mcp_servers, enabled_plugins=enabled_plugins) + except Exception as e: + logger.debug('Failed to report session context', exc_info=e) + + +def session_start_command( + ctx: typer.Context, + ide: Annotated[ + str, + typer.Option( + '--ide', + help='IDE that triggered the session start.', + hidden=True, + ), + ] = AIIDEType.CURSOR.value, +) -> None: + """Handle session start: ensure auth, create conversation, report session context.""" + # Step 1: Ensure authentication + auth_info = get_authorization_info(ctx) + if auth_info is None: + logger.debug('Not authenticated, starting authentication') + try: + auth_manager = AuthManager() + auth_manager.authenticate() + except Exception as err: + handle_auth_exception(ctx, err) + return + else: + logger.debug('Already authenticated') + + # Step 2: Read stdin payload (backward compat: old hooks pipe no stdin) + if sys.stdin.isatty(): + logger.debug('No stdin payload (TTY), skipping session initialization') + return + + stdin_data = sys.stdin.read().strip() + payload = safe_json_parse(stdin_data) + if not payload: + logger.debug('Empty or invalid stdin payload, skipping session initialization') + return + + # Step 3: Build session payload and initialize API client + session_payload = _build_session_payload(payload, ide) + + try: + ai_client = get_ai_security_manager_client(ctx) + except Exception as e: + logger.debug('Failed to initialize AI security client', exc_info=e) + return + + # Step 4: Create conversation + try: + ai_client.create_conversation(session_payload) + except Exception as e: + logger.debug('Failed to create conversation during session start', exc_info=e) + + # Step 5: Report session context (MCP servers) + _report_session_context(ai_client, ide) diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index 35c1d8c9..376b549e 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -17,6 +17,7 @@ class AISecurityManagerClient: _CONVERSATIONS_PATH = 'v4/ai-security/interactions/conversations' _EVENTS_PATH = 'v4/ai-security/interactions/events' + _SESSION_CONTEXT_PATH = 'v4/ai-security/interactions/session-context' def __init__(self, client: CycodeClientBase, service_config: 'AISecurityManagerServiceConfigBase') -> None: self.client = client @@ -88,3 +89,20 @@ def create_event( except Exception as e: logger.debug('Failed to create AI hook event', exc_info=e) # Don't fail the hook if tracking fails + + def report_session_context( + self, + mcp_servers: Optional[dict] = None, + enabled_plugins: Optional[dict] = None, + ) -> None: + """Report session context to the backend.""" + body: dict = { + 'mcp_servers': mcp_servers, + 'enabled_plugins': enabled_plugins, + } + + try: + self.client.post(self._build_endpoint_path(self._SESSION_CONTEXT_PATH), body=body) + except Exception as e: + logger.debug('Failed to report session context', exc_info=e) + # Don't fail the session if reporting fails diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index 1ef1098c..57c25b92 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -67,6 +67,7 @@ def test_handle_before_submit_prompt_disabled( assert result == {'continue': True} mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + mock_ctx.obj['ai_security_client'].create_conversation.assert_not_called() @patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') @@ -80,6 +81,7 @@ def test_handle_before_submit_prompt_no_secrets( assert result == {'continue': True} mock_ctx.obj['ai_security_client'].create_event.assert_called_once() + mock_ctx.obj['ai_security_client'].create_conversation.assert_not_called() 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 diff --git a/tests/cli/commands/ai_guardrails/test_hooks_manager.py b/tests/cli/commands/ai_guardrails/test_hooks_manager.py index ed1ada09..a5732bca 100644 --- a/tests/cli/commands/ai_guardrails/test_hooks_manager.py +++ b/tests/cli/commands/ai_guardrails/test_hooks_manager.py @@ -6,8 +6,8 @@ from pyfakefs.fake_filesystem import FakeFilesystem from cycode.cli.apps.ai_guardrails.consts import ( - CYCODE_ENSURE_AUTH_COMMAND, CYCODE_SCAN_PROMPT_COMMAND, + CYCODE_SESSION_START_COMMAND, AIIDEType, PolicyMode, get_hooks_config, @@ -88,12 +88,13 @@ def test_get_hooks_config_cursor_async() -> None: def test_get_hooks_config_cursor_session_start() -> None: - """Test Cursor hooks config includes sessionStart auth check.""" + """Test Cursor hooks config includes sessionStart with --ide flag.""" config = get_hooks_config(AIIDEType.CURSOR) assert 'sessionStart' in config['hooks'] entries = config['hooks']['sessionStart'] assert len(entries) == 1 - assert entries[0]['command'] == CYCODE_ENSURE_AUTH_COMMAND + assert CYCODE_SESSION_START_COMMAND in entries[0]['command'] + assert '--ide cursor' in entries[0]['command'] def test_get_hooks_config_claude_code_sync() -> None: @@ -118,12 +119,13 @@ def test_get_hooks_config_claude_code_async() -> None: def test_get_hooks_config_claude_code_session_start() -> None: - """Test Claude Code hooks config includes SessionStart auth check.""" + """Test Claude Code hooks config includes SessionStart with --ide flag.""" config = get_hooks_config(AIIDEType.CLAUDE_CODE) assert 'SessionStart' in config['hooks'] entries = config['hooks']['SessionStart'] assert len(entries) == 1 - assert entries[0]['hooks'][0]['command'] == CYCODE_ENSURE_AUTH_COMMAND + assert CYCODE_SESSION_START_COMMAND in entries[0]['hooks'][0]['command'] + assert '--ide claude-code' in entries[0]['hooks'][0]['command'] def test_create_policy_file_warn(fs: FakeFilesystem) -> None: diff --git a/tests/cli/commands/ai_guardrails/test_session_start_command.py b/tests/cli/commands/ai_guardrails/test_session_start_command.py new file mode 100644 index 00000000..48e0ebe3 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/test_session_start_command.py @@ -0,0 +1,392 @@ +"""Tests for session-start command.""" + +import json +from io import StringIO +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.apps.ai_guardrails import session_start_command as _session_start_mod +from cycode.cli.apps.ai_guardrails.session_start_command import session_start_command + + +@pytest.fixture +def mock_ctx() -> MagicMock: + """Create a mock Typer context.""" + ctx = MagicMock(spec=typer.Context) + ctx.obj = {} + return ctx + + +# Auth tests + + +@patch.object(_session_start_mod, 'get_authorization_info') +def test_already_authenticated_skips_auth(mock_get_auth: MagicMock, mock_ctx: MagicMock) -> None: + """When already authenticated, AuthManager should not be called.""" + mock_get_auth.return_value = MagicMock() + + with patch('sys.stdin', new=StringIO('')): + session_start_command(mock_ctx) + + +@patch.object(_session_start_mod, 'AuthManager') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_not_authenticated_triggers_auth( + mock_get_auth: MagicMock, mock_auth_manager_cls: MagicMock, mock_ctx: MagicMock +) -> None: + """When not authenticated, AuthManager.authenticate should be called.""" + mock_get_auth.return_value = None + + with patch('sys.stdin', new=StringIO('')): + session_start_command(mock_ctx) + + mock_auth_manager_cls.return_value.authenticate.assert_called_once() + + +@patch.object(_session_start_mod, 'handle_auth_exception') +@patch.object(_session_start_mod, 'AuthManager') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_auth_failure_handled_gracefully( + mock_get_auth: MagicMock, + mock_auth_manager_cls: MagicMock, + mock_handle_err: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Auth failure should be handled gracefully, not crash.""" + mock_get_auth.return_value = None + mock_auth_manager_cls.return_value.authenticate.side_effect = RuntimeError('auth failed') + + with patch('sys.stdin', new=StringIO('')): + session_start_command(mock_ctx) + + mock_handle_err.assert_called_once() + + +# Stdin / payload tests + + +@patch.object(_session_start_mod, 'get_authorization_info') +def test_tty_stdin_auth_only(mock_get_auth: MagicMock, mock_ctx: MagicMock) -> None: + """When stdin is a TTY (old hooks), only auth is performed.""" + mock_get_auth.return_value = MagicMock() + mock_stdin = MagicMock() + mock_stdin.isatty.return_value = True + + with patch('sys.stdin', new=mock_stdin): + session_start_command(mock_ctx) + + mock_stdin.read.assert_not_called() + + +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_empty_stdin_skips_session_init( + mock_get_auth: MagicMock, mock_get_client: MagicMock, mock_ctx: MagicMock +) -> None: + """Empty stdin should skip session initialization.""" + mock_get_auth.return_value = MagicMock() + + with patch('sys.stdin', new=StringIO('')): + session_start_command(mock_ctx) + + mock_get_client.assert_not_called() + + +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_invalid_json_stdin_skips_session_init( + mock_get_auth: MagicMock, mock_get_client: MagicMock, mock_ctx: MagicMock +) -> None: + """Invalid JSON stdin should skip session initialization.""" + mock_get_auth.return_value = MagicMock() + + with patch('sys.stdin', new=StringIO('not valid json')): + session_start_command(mock_ctx) + + mock_get_client.assert_not_called() + + +# Conversation creation tests + + +@patch.object(_session_start_mod, 'extract_from_claude_transcript') +@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_claude_code_creates_conversation( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_load_config: MagicMock, + mock_extract: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Claude Code payload should create a conversation with session_id, model, email, version.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'user@example.com'}} + mock_extract.return_value = ('2.1.20', 'claude-opus', 'gen-abc') + + transcript_path = '/fake/transcript.jsonl' + payload = {'session_id': 'session-123', 'model': 'claude-opus', 'transcript_path': transcript_path} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + mock_extract.assert_called_once_with(transcript_path) + mock_ai_client.create_conversation.assert_called_once() + call_payload = mock_ai_client.create_conversation.call_args[0][0] + assert call_payload.conversation_id == 'session-123' + assert call_payload.model == 'claude-opus' + assert call_payload.ide_user_email == 'user@example.com' + assert call_payload.ide_provider == 'claude-code' + assert call_payload.ide_version == '2.1.20' + + +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_cursor_creates_conversation( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Cursor payload should create conversation with conversation_id and model.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + + payload = { + 'conversation_id': 'conv-456', + 'user_email': 'cursor-user@example.com', + 'model': 'gpt-4', + 'cursor_version': '0.42.0', + } + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='cursor') + + mock_ai_client.create_conversation.assert_called_once() + call_payload = mock_ai_client.create_conversation.call_args[0][0] + assert call_payload.conversation_id == 'conv-456' + assert call_payload.model == 'gpt-4' + assert call_payload.ide_user_email == 'cursor-user@example.com' + assert call_payload.ide_provider == 'cursor' + + +@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_conversation_creation_failure_non_blocking( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_load_config: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Conversation creation failure should not crash the command.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_ai_client.create_conversation.side_effect = RuntimeError('API down') + mock_get_client.return_value = mock_ai_client + mock_load_config.return_value = None + + payload = {'session_id': 'session-123'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + # Should not raise + + +# MCP server reporting tests + + +@patch.object(_session_start_mod, 'load_claude_settings') +@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_claude_code_reports_mcp_servers( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_load_config: MagicMock, + mock_load_settings: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Claude Code should report MCP servers from ~/.claude.json and enriched plugins.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mcp_servers = { + 'gitlab': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-gitlab']}, + 'filesystem': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-filesystem']}, + } + mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'u@e.com'}, 'mcpServers': mcp_servers} + # Marketplace won't resolve (no extraKnownMarketplaces) so plugin gets {"enabled": True} only. + mock_load_settings.return_value = {'enabledPlugins': {'cycode-dev@cycode-marketplace': True}} + + payload = {'session_id': 'session-123'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + mock_ai_client.report_session_context.assert_called_once_with( + mcp_servers=mcp_servers, + enabled_plugins={'cycode-dev@cycode-marketplace': {'enabled': True}}, + ) + + +@patch.object(_session_start_mod, 'load_claude_settings') +@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_claude_code_merges_plugin_mcp_servers_and_metadata( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_load_config: MagicMock, + mock_load_settings: MagicMock, + mock_ctx: MagicMock, + tmp_path: Path, +) -> None: + """Plugin MCP servers from /.mcp.json should merge into mcp_servers, + and plugin metadata from .claude-plugin/plugin.json should enrich enabled_plugins.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + + # Set up a fake plugin directory on disk. + plugin_dir = tmp_path / 'ai-prompts' + plugin_dir.mkdir() + (plugin_dir / '.mcp.json').write_text( + json.dumps({'mcpServers': {'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}}}) + ) + claude_plugin_dir = plugin_dir / '.claude-plugin' + claude_plugin_dir.mkdir() + (claude_plugin_dir / 'plugin.json').write_text( + json.dumps({'name': 'cycode-dev', 'version': '1.0.28', 'description': 'Shared skills'}) + ) + + user_mcp_servers = {'gitlab': {'command': 'npx'}} + mock_load_config.return_value = {'mcpServers': user_mcp_servers} + mock_load_settings.return_value = { + 'enabledPlugins': {'cycode-dev@cycode-marketplace': True}, + 'extraKnownMarketplaces': {'cycode-marketplace': {'source': {'source': 'directory', 'path': str(plugin_dir)}}}, + } + + payload = {'session_id': 'session-123'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + mock_ai_client.report_session_context.assert_called_once_with( + mcp_servers={ + 'gitlab': {'command': 'npx'}, + 'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}, + }, + enabled_plugins={ + 'cycode-dev@cycode-marketplace': { + 'enabled': True, + 'name': 'cycode-dev', + 'version': '1.0.28', + 'description': 'Shared skills', + 'mcp_server_names': ['aspire'], + } + }, + ) + + +@patch.object(_session_start_mod, 'load_claude_settings') +@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_claude_code_no_mcp_servers_no_plugins_skips_report( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_load_config: MagicMock, + mock_load_settings: MagicMock, + mock_ctx: MagicMock, +) -> None: + """When no mcpServers and no plugins, report_session_context should not be called.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'u@e.com'}} + mock_load_settings.return_value = None + + payload = {'session_id': 'session-123'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + mock_ai_client.report_session_context.assert_not_called() + + +@patch.object(_session_start_mod, 'load_cursor_config') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_cursor_reports_mcp_servers( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_load_cursor: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Cursor should report MCP servers from ~/.cursor/mcp.json.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mcp_servers = {'github': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-github']}} + mock_load_cursor.return_value = {'mcpServers': mcp_servers} + + payload = {'conversation_id': 'conv-456', 'model': 'gpt-4'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='cursor') + + mock_ai_client.report_session_context.assert_called_once_with(mcp_servers=mcp_servers, enabled_plugins={}) + + +@patch.object(_session_start_mod, 'load_cursor_config') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_cursor_no_mcp_servers_skips_report( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_load_cursor: MagicMock, + mock_ctx: MagicMock, +) -> None: + """Cursor with no MCP config file should skip report_session_context.""" + mock_get_auth.return_value = MagicMock() + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_load_cursor.return_value = None + + payload = {'conversation_id': 'conv-456', 'model': 'gpt-4'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='cursor') + + mock_ai_client.report_session_context.assert_not_called() + + +@patch.object(_session_start_mod, 'handle_auth_exception') +@patch.object(_session_start_mod, 'AuthManager') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_unauthenticated_skips_session_init( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_auth_manager_cls: MagicMock, + mock_handle_err: MagicMock, + mock_ctx: MagicMock, +) -> None: + """When auth fails, session initialization should be skipped entirely.""" + mock_get_auth.return_value = None + mock_auth_manager_cls.return_value.authenticate.side_effect = RuntimeError('auth failed') + + payload = {'session_id': 'session-123'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + mock_get_client.assert_not_called() From 4213d72530e24f82bc3543d16990d0be2084289b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:09:20 +0100 Subject: [PATCH 277/347] Bump pygments from 2.19.2 to 2.20.0 (#420) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/poetry.lock b/poetry.lock index 582ee5a5..f810887f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.3 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" @@ -1163,14 +1163,14 @@ files = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" description = "Pygments is a syntax highlighting package written in Python." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main", "test"] files = [ - {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, - {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, + {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, + {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, ] [package.extras] From fa1fe95a7373795435158295d68c67be8cc09754 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:46:41 +0100 Subject: [PATCH 278/347] Bump docker/setup-qemu-action from 3.7.0 to 4.0.0 (#427) 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 fe38b63a..30552e74 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -61,7 +61,7 @@ jobs: echo "CLI_VERSION=$(poetry version --short)" >> $GITHUB_OUTPUT - name: Set up QEMU - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 + uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 From 980d72223cbf7637f440a95e7aef8480524aae37 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:53:52 +0100 Subject: [PATCH 279/347] Bump actions/checkout from 4.3.1 to 6.0.2 (#426) 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 e410ba57..74abc748 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -50,7 +50,7 @@ jobs: uploads.github.com - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 30552e74..af71fac6 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 0e17facd..f3d081d6 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -28,7 +28,7 @@ jobs: *.sigstore.dev - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a3e3d26..462e2362 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: *.sigstore.dev - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 3099cbd7..038ee4ad 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -21,7 +21,7 @@ jobs: pypi.org - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cfb1aa21..968a45ad 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,7 +23,7 @@ jobs: *.ingest.us.sentry.io - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index 1fdb091b..70bd9128 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -36,7 +36,7 @@ jobs: *.ingest.us.sentry.io - name: Checkout repository - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 From b2c22ba72608e371880c61bb423faa49c4bccd42 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 15:05:23 +0100 Subject: [PATCH 280/347] Bump docker/login-action from 3.7.0 to 4.0.0 (#430) 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 af71fac6..e22c3205 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -68,7 +68,7 @@ jobs: - name: Login to Docker Hub if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') }} - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: username: ${{ secrets.DOCKERHUB_USER }} password: ${{ secrets.DOCKERHUB_PASSWORD }} From f0a093313db7ead8fe6b48832516518395b22dcc Mon Sep 17 00:00:00 2001 From: RoniCycode <142726722+RoniCycode@users.noreply.github.com> Date: Thu, 23 Apr 2026 10:35:21 +0300 Subject: [PATCH 281/347] CM-62381-remove-matcher (#438) Co-authored-by: Maor Davidzon <56628808+MaorDavidzon@users.noreply.github.com> --- cycode/cli/apps/ai_guardrails/consts.py | 1 - cycode/cli/apps/ai_guardrails/scan/payload.py | 2 ++ cycode/cli/apps/ai_guardrails/session_start_command.py | 1 + cycode/cyclient/ai_security_manager_client.py | 1 + 4 files changed, 4 insertions(+), 1 deletion(-) diff --git a/cycode/cli/apps/ai_guardrails/consts.py b/cycode/cli/apps/ai_guardrails/consts.py index 837096c8..2895c8d1 100644 --- a/cycode/cli/apps/ai_guardrails/consts.py +++ b/cycode/cli/apps/ai_guardrails/consts.py @@ -118,7 +118,6 @@ def _get_claude_code_hooks_config(async_mode: bool = False) -> dict: 'hooks': { 'SessionStart': [ { - 'matcher': 'startup', 'hooks': [{'type': 'command', 'command': f'{CYCODE_SESSION_START_COMMAND} --ide claude-code'}], } ], diff --git a/cycode/cli/apps/ai_guardrails/scan/payload.py b/cycode/cli/apps/ai_guardrails/scan/payload.py index ada40a3c..d8fd4c53 100644 --- a/cycode/cli/apps/ai_guardrails/scan/payload.py +++ b/cycode/cli/apps/ai_guardrails/scan/payload.py @@ -133,6 +133,8 @@ class AIHookPayload: ide_provider: str = None # AIIDEType value (e.g., 'cursor', 'claude-code') ide_version: Optional[str] = None + source: Optional[str] = None + # Event-specific data prompt: Optional[str] = None # For prompt events file_path: Optional[str] = None # For file_read events diff --git a/cycode/cli/apps/ai_guardrails/session_start_command.py b/cycode/cli/apps/ai_guardrails/session_start_command.py index a33dc439..5218afde 100644 --- a/cycode/cli/apps/ai_guardrails/session_start_command.py +++ b/cycode/cli/apps/ai_guardrails/session_start_command.py @@ -39,6 +39,7 @@ def _build_session_payload(payload: dict, ide: str) -> AIHookPayload: model=payload.get('model'), ide_provider=AIIDEType.CLAUDE_CODE.value, ide_version=ide_version, + source=payload.get('source'), ) # Cursor diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index 376b549e..f4ae31db 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -42,6 +42,7 @@ def create_conversation(self, payload: 'AIHookPayload') -> Optional[str]: 'model': payload.model, 'ide_provider': payload.ide_provider, 'ide_version': payload.ide_version, + 'source': payload.source, } try: From 7e49a742888c5fecac0a41c1a60f61b407ba79a9 Mon Sep 17 00:00:00 2001 From: omerr-cycode Date: Mon, 27 Apr 2026 15:40:31 +0300 Subject: [PATCH 282/347] CM-63288 cli add error code 2 for scan errors (#440) --- README.md | 8 +++ cycode/cli/apps/scan/scan_command.py | 5 +- cycode/cli/consts.py | 1 + .../sca/nuget/restore_nuget_dependencies.py | 12 ++++- tests/cli/commands/scan/test_scan_command.py | 52 +++++++++++++++++++ 5 files changed, 76 insertions(+), 2 deletions(-) create mode 100644 tests/cli/commands/scan/test_scan_command.py diff --git a/README.md b/README.md index 806390d0..2604bb0f 100644 --- a/README.md +++ b/README.md @@ -802,6 +802,14 @@ cycode scan -t sca --stop-on-error path ~/home/git/codebase This is useful in CI pipelines where a silent failure would produce an incomplete scan result. When `--stop-on-error` is triggered you can either fix the underlying issue or, for SCA restore failures specifically, add `--no-restore` to skip lockfile generation and scan direct dependencies only. +When `--stop-on-error` is used, the CLI distinguishes between scan errors and policy violations via exit codes: + +| Exit code | Meaning | +|-----------|---------| +| `0` | Scan completed with no violations | +| `1` | Scan completed and violations were found | +| `2` | Scan aborted due to an error (only when `--stop-on-error` is set) | + ### 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. diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 62697357..9b2aa280 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -17,6 +17,7 @@ from cycode.cli.consts import ( ISSUE_DETECTED_STATUS_CODE, NO_ISSUES_STATUS_CODE, + SCAN_ERROR_STATUS_CODE, ) from cycode.cli.files_collector.file_excluder import excluder from cycode.cli.utils import scan_utils @@ -187,7 +188,9 @@ def scan_command_result_callback(ctx: click.Context, *_, **__) -> None: raise typer.Exit(0) exit_code = NO_ISSUES_STATUS_CODE - if scan_utils.is_scan_failed(ctx): + if ctx.obj.get('did_fail') and ctx.obj.get('stop_on_error'): + exit_code = SCAN_ERROR_STATUS_CODE + elif scan_utils.is_scan_failed(ctx): exit_code = ISSUE_DETECTED_STATUS_CODE raise typer.Exit(exit_code) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 31ab6ef9..108aa0e1 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -277,6 +277,7 @@ ISSUE_DETECTED_STATUS_CODE = 1 NO_ISSUES_STATUS_CODE = 0 +SCAN_ERROR_STATUS_CODE = 2 LICENSE_COMPLIANCE_POLICY_ID = '8f681450-49e1-4f7e-85b7-0c8fe84b3a35' PACKAGE_VULNERABILITY_POLICY_ID = '9369d10a-9ac0-48d3-9921-5de7fe9a37a7' 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 95ced0ff..9bd01cd0 100644 --- a/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py +++ b/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py @@ -15,7 +15,17 @@ 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]]: - return [['dotnet', 'restore', manifest_file_path, '--use-lock-file', '--verbosity', 'quiet']] + return [ + [ + 'dotnet', + 'restore', + manifest_file_path, + '--use-lock-file', + '--verbosity', + 'quiet', + '--ignore-failed-sources', + ] + ] def get_lock_file_name(self) -> str: return NUGET_LOCK_FILE_NAME diff --git a/tests/cli/commands/scan/test_scan_command.py b/tests/cli/commands/scan/test_scan_command.py new file mode 100644 index 00000000..de218da5 --- /dev/null +++ b/tests/cli/commands/scan/test_scan_command.py @@ -0,0 +1,52 @@ +import click +import pytest +import typer + +from cycode.cli.apps.scan.scan_command import scan_command_result_callback +from cycode.cli.consts import ISSUE_DETECTED_STATUS_CODE, NO_ISSUES_STATUS_CODE, SCAN_ERROR_STATUS_CODE + + +def _make_ctx(**obj_overrides: object) -> click.Context: + obj = { + 'soft_fail': False, + 'did_fail': False, + 'issue_detected': False, + 'stop_on_error': False, + } + obj.update(obj_overrides) + ctx = click.Context(click.Command('scan')) + ctx.obj = obj + return ctx + + +def _invoke_result_callback(ctx: click.Context) -> int: + with pytest.raises(typer.Exit) as exc_info, ctx: + scan_command_result_callback() + return exc_info.value.exit_code + + +class TestScanCommandResultCallback: + def test_no_issues_no_errors_exits_zero(self) -> None: + assert _invoke_result_callback(_make_ctx()) == NO_ISSUES_STATUS_CODE + + def test_issue_detected_exits_one(self) -> None: + assert _invoke_result_callback(_make_ctx(issue_detected=True)) == ISSUE_DETECTED_STATUS_CODE + + def test_did_fail_without_stop_on_error_exits_one(self) -> None: + assert _invoke_result_callback(_make_ctx(did_fail=True)) == ISSUE_DETECTED_STATUS_CODE + + def test_did_fail_with_stop_on_error_exits_two(self) -> None: + assert _invoke_result_callback(_make_ctx(did_fail=True, stop_on_error=True)) == SCAN_ERROR_STATUS_CODE + + def test_issue_detected_with_stop_on_error_exits_one(self) -> None: + # stop_on_error only affects the error code path, not violations + assert _invoke_result_callback(_make_ctx(issue_detected=True, stop_on_error=True)) == ISSUE_DETECTED_STATUS_CODE + + def test_soft_fail_overrides_violations(self) -> None: + assert _invoke_result_callback(_make_ctx(soft_fail=True, issue_detected=True)) == NO_ISSUES_STATUS_CODE + + def test_soft_fail_overrides_stop_on_error(self) -> None: + assert ( + _invoke_result_callback(_make_ctx(soft_fail=True, did_fail=True, stop_on_error=True)) + == NO_ISSUES_STATUS_CODE + ) From a5dc614203d9951c94100c0d4e2a735ecb3255c5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:04:43 +0100 Subject: [PATCH 283/347] Bump gitpython from 3.1.45 to 3.1.47 (#439) 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 f810887f..36bd6515 100644 --- a/poetry.lock +++ b/poetry.lock @@ -582,14 +582,14 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.45" +version = "3.1.47" 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.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77"}, - {file = "gitpython-3.1.45.tar.gz", hash = "sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c"}, + {file = "gitpython-3.1.47-py3-none-any.whl", hash = "sha256:489f590edfd6d20571b2c0e72c6a6ac6915ee8b8cd04572330e3842207a78905"}, + {file = "gitpython-3.1.47.tar.gz", hash = "sha256:dba27f922bd2b42cb54c87a8ab3cb6beb6bf07f3d564e21ac848913a05a8a3cd"}, ] [package.dependencies] @@ -597,8 +597,8 @@ 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"] -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\""] +doc = ["sphinx (>=7.4.7,<8)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"] +test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy (==1.18.2) ; python_version >= \"3.9\"", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""] [[package]] name = "h11" @@ -2042,4 +2042,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "25dc6986a2a4572b689edb26f9184faab8bee8e3a569f8e0cdc8ac35ded0b9fc" +content-hash = "d9569b59b94a3333764ae66390d168630ca1c5988e7793167d15634ca9c502e2" diff --git a/pyproject.toml b/pyproject.toml index 0ed8d8c9..f6ce80f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ click = ">=8.1.0,<8.2.0" 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" +gitpython = ">=3.1.47,<3.2.0" arrow = ">=1.0.0,<1.5.0" requests = ">=2.32.4,<3.0" urllib3 = ">=2.4.0,<3.0.0" From b7451e5f10031bfab2150ad00ea436b341a31bcf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 18:14:42 +0000 Subject: [PATCH 284/347] Bump cycodelabs/cimon-action from 0.9.4 to 0.10.0 (#429) 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/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 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 74abc748..e0a963d9 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Run Cimon if: matrix.os == 'ubuntu-22.04' - uses: cycodelabs/cimon-action@1c3e30d508634b3f4a60b02843126c9f93944d80 # v0.9.4 + uses: cycodelabs/cimon-action@f99ad5557cb80964bc2b2e76a47bf4b5ba6e323b # v0.10.0 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index f3d081d6..4464f0e4 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@1c3e30d508634b3f4a60b02843126c9f93944d80 # v0.9.4 + uses: cycodelabs/cimon-action@f99ad5557cb80964bc2b2e76a47bf4b5ba6e323b # v0.10.0 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 462e2362..b634c0f2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@1c3e30d508634b3f4a60b02843126c9f93944d80 # v0.9.4 + uses: cycodelabs/cimon-action@f99ad5557cb80964bc2b2e76a47bf4b5ba6e323b # v0.10.0 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 038ee4ad..8e64e255 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Run Cimon - uses: cycodelabs/cimon-action@1c3e30d508634b3f4a60b02843126c9f93944d80 # v0.9.4 + uses: cycodelabs/cimon-action@f99ad5557cb80964bc2b2e76a47bf4b5ba6e323b # v0.10.0 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 968a45ad..873005fb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@1c3e30d508634b3f4a60b02843126c9f93944d80 # v0.9.4 + uses: cycodelabs/cimon-action@f99ad5557cb80964bc2b2e76a47bf4b5ba6e323b # v0.10.0 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index 70bd9128..7e3badb6 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Run Cimon if: matrix.os == 'ubuntu-latest' - uses: cycodelabs/cimon-action@1c3e30d508634b3f4a60b02843126c9f93944d80 # v0.9.4 + uses: cycodelabs/cimon-action@f99ad5557cb80964bc2b2e76a47bf4b5ba6e323b # v0.10.0 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} From a9e0197184e029d6bb94772ce2718ad1c3f82a66 Mon Sep 17 00:00:00 2001 From: omerr-cycode Date: Wed, 6 May 2026 09:26:37 +0300 Subject: [PATCH 285/347] CM-55107 add support for UV package manager for SCA scans (#441) --- cycode/cli/consts.py | 3 + .../sca/python/restore_uv_dependencies.py | 59 ++++++++ .../files_collector/sca/sca_file_collector.py | 2 + .../python/test_restore_uv_dependencies.py | 138 ++++++++++++++++++ 4 files changed, 202 insertions(+) create mode 100644 cycode/cli/files_collector/sca/python/restore_uv_dependencies.py create mode 100644 tests/cli/files_collector/sca/python/test_restore_uv_dependencies.py diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 108aa0e1..52a6827d 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -91,6 +91,7 @@ 'build.scala', 'build.sbt.lock', 'pyproject.toml', + 'uv.lock', 'poetry.lock', 'pipfile', 'pipfile.lock', @@ -124,6 +125,7 @@ '.build', '.dart_tool', '.pub', + '.uv', ) PROJECT_FILES_BY_ECOSYSTEM_MAP = { @@ -145,6 +147,7 @@ '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_uv': ['pyproject.toml', 'uv.lock'], 'pypi_poetry': ['pyproject.toml', 'poetry.lock'], 'pypi_pipenv': ['Pipfile', 'Pipfile.lock'], 'pypi_requirements': ['requirements.txt'], diff --git a/cycode/cli/files_collector/sca/python/restore_uv_dependencies.py b/cycode/cli/files_collector/sca/python/restore_uv_dependencies.py new file mode 100644 index 00000000..c05d857c --- /dev/null +++ b/cycode/cli/files_collector/sca/python/restore_uv_dependencies.py @@ -0,0 +1,59 @@ +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('UV Restore Dependencies') + +UV_MANIFEST_FILE_NAME = 'pyproject.toml' +UV_LOCK_FILE_NAME = 'uv.lock' + +_UV_TOOL_SECTION = '[tool.uv]' + + +def _indicates_uv(pyproject_content: Optional[str]) -> bool: + """Return True if pyproject.toml content signals that this project uses UV.""" + if not pyproject_content: + return False + return _UV_TOOL_SECTION in pyproject_content + + +class RestoreUvDependencies(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 != UV_MANIFEST_FILE_NAME: + return False + + manifest_dir = self.get_manifest_dir(document) + if manifest_dir and (Path(manifest_dir) / UV_LOCK_FILE_NAME).is_file(): + return True + + return _indicates_uv(document.content) + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / UV_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, UV_LOCK_FILE_NAME) + logger.debug('Using existing uv.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['uv', 'lock']] + + def get_lock_file_name(self) -> str: + return UV_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [UV_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 c9c17ebf..b57061b0 100644 --- a/cycode/cli/files_collector/sca/sca_file_collector.py +++ b/cycode/cli/files_collector/sca/sca_file_collector.py @@ -18,6 +18,7 @@ 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.python.restore_uv_dependencies import RestoreUvDependencies 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 @@ -159,6 +160,7 @@ def _get_restore_handlers(ctx: typer.Context, is_git_diff: bool) -> list[BaseRes 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), + RestoreUvDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be before Poetry for pyproject.toml 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/python/test_restore_uv_dependencies.py b/tests/cli/files_collector/sca/python/test_restore_uv_dependencies.py new file mode 100644 index 00000000..70e4e7ae --- /dev/null +++ b/tests/cli/files_collector/sca/python/test_restore_uv_dependencies.py @@ -0,0 +1,138 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.python.restore_uv_dependencies import ( + UV_LOCK_FILE_NAME, + RestoreUvDependencies, +) +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_uv(mock_ctx: typer.Context) -> RestoreUvDependencies: + return RestoreUvDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_pyproject_toml_with_uv_lock_matches(self, restore_uv: RestoreUvDependencies, tmp_path: Path) -> None: + (tmp_path / 'pyproject.toml').write_text('[build-system]\nrequires = ["hatchling"]\n') + (tmp_path / 'uv.lock').write_text('version = 1\n') + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[build-system]\nrequires = ["hatchling"]\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + assert restore_uv.is_project(doc) is True + + def test_pyproject_toml_with_tool_uv_section_matches(self, restore_uv: RestoreUvDependencies) -> None: + content = '[tool.uv]\ndev-dependencies = ["pytest"]\n' + doc = Document('pyproject.toml', content) + assert restore_uv.is_project(doc) is True + + def test_pyproject_toml_without_uv_signals_does_not_match( + self, restore_uv: RestoreUvDependencies, tmp_path: Path + ) -> None: + content = '[tool.poetry]\nname = "my-project"\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_uv.is_project(doc) is False + + def test_requirements_txt_does_not_match(self, restore_uv: RestoreUvDependencies) -> None: + doc = Document('requirements.txt', 'requests==2.31.0\n') + assert restore_uv.is_project(doc) is False + + def test_empty_content_does_not_match(self, restore_uv: RestoreUvDependencies, 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_uv.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_uv_lock_returned_directly(self, restore_uv: RestoreUvDependencies, tmp_path: Path) -> None: + lock_content = 'version = 1\n\n[[package]]\nname = "requests"\n' + (tmp_path / 'pyproject.toml').write_text('[tool.uv]\n') + (tmp_path / 'uv.lock').write_text(lock_content) + + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[tool.uv]\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + result = restore_uv.try_restore_dependencies(doc) + + assert result is not None + assert UV_LOCK_FILE_NAME in result.path + assert result.content == lock_content + + def test_get_lock_file_name(self, restore_uv: RestoreUvDependencies) -> None: + assert restore_uv.get_lock_file_name() == UV_LOCK_FILE_NAME + + def test_get_commands_returns_uv_lock(self, restore_uv: RestoreUvDependencies) -> None: + commands = restore_uv.get_commands('/path/to/pyproject.toml') + assert commands == [['uv', 'lock']] + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_uv: RestoreUvDependencies, tmp_path: Path + ) -> None: + manifest_content = '[tool.uv]\ndev-dependencies = ["pytest"]\n' + (tmp_path / 'pyproject.toml').write_text(manifest_content) + doc = Document( + str(tmp_path / 'pyproject.toml'), manifest_content, absolute_path=str(tmp_path / 'pyproject.toml') + ) + lock_path = tmp_path / UV_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('version = 1\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_uv.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{UV_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_uv: RestoreUvDependencies, tmp_path: Path) -> None: + lock_content = 'version = 1\n\n[[package]]\nname = "requests"\n' + (tmp_path / 'pyproject.toml').write_text('[tool.uv]\n') + lock_path = tmp_path / UV_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[tool.uv]\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + + result = restore_uv.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {UV_LOCK_FILE_NAME} must not be deleted' From 865aa40b977ec76a6f503323d01f0a9b3501dd2d Mon Sep 17 00:00:00 2001 From: RoniCycode <142726722+RoniCycode@users.noreply.github.com> Date: Wed, 6 May 2026 10:30:41 +0300 Subject: [PATCH 286/347] CM-62381-add-user-email-for-claude-ide (#451) --- .../cli/apps/ai_guardrails/session_start_command.py | 12 ++++++++---- cycode/cyclient/ai_security_manager_client.py | 2 ++ .../ai_guardrails/test_session_start_command.py | 10 +++++++--- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/session_start_command.py b/cycode/cli/apps/ai_guardrails/session_start_command.py index 5218afde..f2d5031c 100644 --- a/cycode/cli/apps/ai_guardrails/session_start_command.py +++ b/cycode/cli/apps/ai_guardrails/session_start_command.py @@ -1,5 +1,5 @@ import sys -from typing import TYPE_CHECKING, Annotated +from typing import TYPE_CHECKING, Annotated, Optional import typer @@ -79,7 +79,7 @@ def _get_cursor_session_context() -> tuple[dict, dict]: return mcp_servers, {} -def _report_session_context(ai_client: 'AISecurityManagerClient', ide: str) -> None: +def _report_session_context(ai_client: 'AISecurityManagerClient', ide: str, user_email: Optional[str]) -> None: """Report IDE session context to the AI security manager. Never raises.""" try: if ide == AIIDEType.CLAUDE_CODE: @@ -91,7 +91,11 @@ def _report_session_context(ai_client: 'AISecurityManagerClient', ide: str) -> N if not mcp_servers and not enabled_plugins: return - ai_client.report_session_context(mcp_servers=mcp_servers, enabled_plugins=enabled_plugins) + ai_client.report_session_context( + mcp_servers=mcp_servers, + enabled_plugins=enabled_plugins, + user_email=user_email, + ) except Exception as e: logger.debug('Failed to report session context', exc_info=e) @@ -148,4 +152,4 @@ def session_start_command( logger.debug('Failed to create conversation during session start', exc_info=e) # Step 5: Report session context (MCP servers) - _report_session_context(ai_client, ide) + _report_session_context(ai_client, ide, session_payload.ide_user_email) diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index f4ae31db..f9b7b124 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -95,11 +95,13 @@ def report_session_context( self, mcp_servers: Optional[dict] = None, enabled_plugins: Optional[dict] = None, + user_email: Optional[str] = None, ) -> None: """Report session context to the backend.""" body: dict = { 'mcp_servers': mcp_servers, 'enabled_plugins': enabled_plugins, + 'user_email': user_email, } try: diff --git a/tests/cli/commands/ai_guardrails/test_session_start_command.py b/tests/cli/commands/ai_guardrails/test_session_start_command.py index 48e0ebe3..82a13043 100644 --- a/tests/cli/commands/ai_guardrails/test_session_start_command.py +++ b/tests/cli/commands/ai_guardrails/test_session_start_command.py @@ -222,7 +222,7 @@ def test_claude_code_reports_mcp_servers( 'gitlab': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-gitlab']}, 'filesystem': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-filesystem']}, } - mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'u@e.com'}, 'mcpServers': mcp_servers} + mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'test@test.com'}, 'mcpServers': mcp_servers} # Marketplace won't resolve (no extraKnownMarketplaces) so plugin gets {"enabled": True} only. mock_load_settings.return_value = {'enabledPlugins': {'cycode-dev@cycode-marketplace': True}} @@ -234,6 +234,7 @@ def test_claude_code_reports_mcp_servers( mock_ai_client.report_session_context.assert_called_once_with( mcp_servers=mcp_servers, enabled_plugins={'cycode-dev@cycode-marketplace': {'enabled': True}}, + user_email='test@test.com', ) @@ -293,6 +294,7 @@ def test_claude_code_merges_plugin_mcp_servers_and_metadata( 'mcp_server_names': ['aspire'], } }, + user_email=None, ) @@ -311,7 +313,7 @@ def test_claude_code_no_mcp_servers_no_plugins_skips_report( mock_get_auth.return_value = MagicMock() mock_ai_client = MagicMock() mock_get_client.return_value = mock_ai_client - mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'u@e.com'}} + mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'test@test.com'}} mock_load_settings.return_value = None payload = {'session_id': 'session-123'} @@ -343,7 +345,9 @@ def test_cursor_reports_mcp_servers( with patch('sys.stdin', new=StringIO(json.dumps(payload))): session_start_command(mock_ctx, ide='cursor') - mock_ai_client.report_session_context.assert_called_once_with(mcp_servers=mcp_servers, enabled_plugins={}) + mock_ai_client.report_session_context.assert_called_once_with( + mcp_servers=mcp_servers, enabled_plugins={}, user_email=None + ) @patch.object(_session_start_mod, 'load_cursor_config') From c45123c9bc6961f928d6a1ae137beeef0b6b5f57 Mon Sep 17 00:00:00 2001 From: Philip Hayton Date: Tue, 12 May 2026 13:41:08 +0100 Subject: [PATCH 287/347] Update CODEOWNERS (#455) --- CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index f05ffdb9..e59df91b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1 +1 @@ -* @elsapet @gotbadger @mateusz-sterczewski +* @avishaiamiel @omerr-cycode From 050789cfd8ef13e7d5c7123e6d6b8a958e5cedeb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 14:22:13 +0000 Subject: [PATCH 288/347] Bump gitpython from 3.1.47 to 3.1.50 (#454) 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 36bd6515..2834c3c3 100644 --- a/poetry.lock +++ b/poetry.lock @@ -582,14 +582,14 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.47" +version = "3.1.50" 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.47-py3-none-any.whl", hash = "sha256:489f590edfd6d20571b2c0e72c6a6ac6915ee8b8cd04572330e3842207a78905"}, - {file = "gitpython-3.1.47.tar.gz", hash = "sha256:dba27f922bd2b42cb54c87a8ab3cb6beb6bf07f3d564e21ac848913a05a8a3cd"}, + {file = "gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9"}, + {file = "gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc"}, ] [package.dependencies] @@ -2042,4 +2042,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "d9569b59b94a3333764ae66390d168630ca1c5988e7793167d15634ca9c502e2" +content-hash = "51f62d621288e2a4770e51f64bcde4a0f96017f8367e7bda660ed6816f6cf31a" diff --git a/pyproject.toml b/pyproject.toml index f6ce80f6..d07ef360 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ click = ">=8.1.0,<8.2.0" colorama = ">=0.4.3,<0.5.0" pyyaml = ">=6.0,<7.0" marshmallow = ">=3.15.0,<4.0.0" -gitpython = ">=3.1.47,<3.2.0" +gitpython = ">=3.1.50,<3.2.0" arrow = ">=1.0.0,<1.5.0" requests = ">=2.32.4,<3.0" urllib3 = ">=2.4.0,<3.0.0" From 2199836813d5599e6113a5263fad6983923b10a6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 May 2026 14:34:14 +0000 Subject: [PATCH 289/347] Bump cycodelabs/cimon-action from 0.10.0 to 0.10.1 (#450) 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/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 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index e0a963d9..3b2d3444 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Run Cimon if: matrix.os == 'ubuntu-22.04' - uses: cycodelabs/cimon-action@f99ad5557cb80964bc2b2e76a47bf4b5ba6e323b # v0.10.0 + uses: cycodelabs/cimon-action@3ca67e875f34772093aa3bf3c185a711720bf5d9 # v0.10.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 4464f0e4..8475352f 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@f99ad5557cb80964bc2b2e76a47bf4b5ba6e323b # v0.10.0 + uses: cycodelabs/cimon-action@3ca67e875f34772093aa3bf3c185a711720bf5d9 # v0.10.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b634c0f2..c0bc0409 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@f99ad5557cb80964bc2b2e76a47bf4b5ba6e323b # v0.10.0 + uses: cycodelabs/cimon-action@3ca67e875f34772093aa3bf3c185a711720bf5d9 # v0.10.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 8e64e255..5d709182 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Run Cimon - uses: cycodelabs/cimon-action@f99ad5557cb80964bc2b2e76a47bf4b5ba6e323b # v0.10.0 + uses: cycodelabs/cimon-action@3ca67e875f34772093aa3bf3c185a711720bf5d9 # v0.10.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 873005fb..b09d7bbc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@f99ad5557cb80964bc2b2e76a47bf4b5ba6e323b # v0.10.0 + uses: cycodelabs/cimon-action@3ca67e875f34772093aa3bf3c185a711720bf5d9 # v0.10.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index 7e3badb6..ea264012 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Run Cimon if: matrix.os == 'ubuntu-latest' - uses: cycodelabs/cimon-action@f99ad5557cb80964bc2b2e76a47bf4b5ba6e323b # v0.10.0 + uses: cycodelabs/cimon-action@3ca67e875f34772093aa3bf3c185a711720bf5d9 # v0.10.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} From e42ebf645399035aa8fc7b9762a5f05906d3103d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 08:40:27 +0300 Subject: [PATCH 290/347] Bump actions/cache from 5.0.3 to 5.0.5 (#447) 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 3b2d3444..a843dcf5 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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 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 e22c3205..ef92a88d 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -37,7 +37,7 @@ jobs: - name: Load cached Poetry setup id: cached_poetry - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 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 8475352f..fd183691 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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c0bc0409..acee4571 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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 5d709182..fcc7a882 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -30,7 +30,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b09d7bbc..bd275986 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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 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 ea264012..b6fee12e 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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.local key: poetry-${{ matrix.os }}-${{ matrix.python-version }}-3 # increment to reset cache From ff91b13678b1cdf84d0520f4210b52ad665ba12f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 08:55:49 +0300 Subject: [PATCH 291/347] Bump patch-ng from 1.19.0 to 1.19.1 (#444) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 7 ++++--- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/poetry.lock b/poetry.lock index 2834c3c3..f5bec698 100644 --- a/poetry.lock +++ b/poetry.lock @@ -904,13 +904,14 @@ files = [ [[package]] name = "patch-ng" -version = "1.19.0" +version = "1.19.1" description = "Library to parse and apply unified diffs." optional = false python-versions = ">=3.6" groups = ["main"] files = [ - {file = "patch-ng-1.19.0.tar.gz", hash = "sha256:27484792f4ac1c15fe2f3e4cecf74bb9833d33b75c715b71d199f7e1e7d1f786"}, + {file = "patch_ng-1.19.1-py3-none-any.whl", hash = "sha256:d45fd47b3f74b48c3e336690341876bb26244a077a06f5f7e6e47c19c15c1ca4"}, + {file = "patch_ng-1.19.1.tar.gz", hash = "sha256:036a3cc00134ec53f37e92333958ee75e117f2e62a5ec2b85c7122e5e815c29e"}, ] [[package]] @@ -2042,4 +2043,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "51f62d621288e2a4770e51f64bcde4a0f96017f8367e7bda660ed6816f6cf31a" +content-hash = "9462529cbf317006a912a7debe8552a9217c22740ddc95330eae39fbaf8112f1" diff --git a/pyproject.toml b/pyproject.toml index d07ef360..2980b706 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.19.0" +patch-ng = "1.19.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'" } From 82c263bd744b9288933efe09547ee803295eee74 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 09:05:17 +0300 Subject: [PATCH 292/347] Bump dunamai from 1.26.0 to 1.26.1 (#445) 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 f5bec698..1a6ce6ee 100644 --- a/poetry.lock +++ b/poetry.lock @@ -533,14 +533,14 @@ test-randomorder = ["pytest-randomly"] [[package]] name = "dunamai" -version = "1.26.0" +version = "1.26.1" description = "Dynamic version generation" optional = false python-versions = ">=3.5" groups = ["executable"] files = [ - {file = "dunamai-1.26.0-py3-none-any.whl", hash = "sha256:f584edf0fda0d308cce0961f807bc90a8fe3d9ff4d62f94e72eca7b43f0ed5f6"}, - {file = "dunamai-1.26.0.tar.gz", hash = "sha256:5396ac43aa20ed059040034e9f9798c7464cf4334c6fc3da3732e29273a2f97d"}, + {file = "dunamai-1.26.1-py3-none-any.whl", hash = "sha256:2727d939c5b4257cb01ea404372803b477f5176e5a347c43beaf89cd5072e853"}, + {file = "dunamai-1.26.1.tar.gz", hash = "sha256:3b46007bd65b00b4824ead0a1aee365fd22d0ec2b9c219497d4fd48f52860c8b"}, ] [package.dependencies] @@ -2043,4 +2043,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "9462529cbf317006a912a7debe8552a9217c22740ddc95330eae39fbaf8112f1" +content-hash = "b67d2f0ceadcf2fbd351b056596da8a04656a3774c2cc26e13cf678b6f31561f" diff --git a/pyproject.toml b/pyproject.toml index 2980b706..c3d10f0a 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.27.0" +dunamai = ">=1.26.1,<1.27.0" [tool.poetry.group.dev.dependencies] ruff = "0.11.7" From da8a2ab31c874fd06586d2dffc9a082b23a5e089 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 09:17:18 +0300 Subject: [PATCH 293/347] Bump docker/build-push-action from 7.0.0 to 7.1.0 (#448) 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 ef92a88d..f19b4e2d 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -76,7 +76,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@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . platforms: linux/amd64,linux/arm64 @@ -86,7 +86,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@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . platforms: linux/amd64,linux/arm64 From e0e52810acd8e6f3397c5c840aad9fe4d30bc15f Mon Sep 17 00:00:00 2001 From: Amit Moskovitz Date: Mon, 18 May 2026 20:24:20 +0300 Subject: [PATCH 294/347] CM-64214: Fix missing dependency paths in Maven CLI scan (#456) Co-authored-by: Claude Sonnet 4.6 --- .../sca/maven/restore_maven_dependencies.py | 23 +++- .../sca/test_restore_maven_dependencies.py | 105 ++++++++++++++++++ 2 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 tests/cli/files_collector/sca/test_restore_maven_dependencies.py 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 740ccca9..53ed269f 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 json from os import path from pathlib import Path from typing import Optional @@ -20,6 +21,16 @@ MAVEN_DEP_TREE_FILE_NAME = 'bcde.mvndeps' +def _has_dependency_graph(bom_content: Optional[str]) -> bool: + try: + if not bom_content: + return False + bom = json.loads(bom_content) + return any(dep.get('dependsOn') for dep in bom.get('dependencies', [])) + except Exception: + return False + + class RestoreMavenDependencies(BaseRestoreDependencies): def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None: super().__init__(ctx, is_git_diff, command_timeout) @@ -46,8 +57,16 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]: if document.content is None: return self.restore_from_secondary_command(document, manifest_file_path) - # super() reads the content and cleans up any generated file; no re-read needed - return super().try_restore_dependencies(document) + restore_dependencies_document = super().try_restore_dependencies(document) + if restore_dependencies_document is None: + return None + + if not _has_dependency_graph(restore_dependencies_document.content): + fallback = self.restore_from_secondary_command(document, manifest_file_path) + if fallback is not None and fallback.content is not None: + return fallback + + return restore_dependencies_document def restore_from_secondary_command(self, document: Document, manifest_file_path: str) -> Optional[Document]: restore_content = execute_commands( diff --git a/tests/cli/files_collector/sca/test_restore_maven_dependencies.py b/tests/cli/files_collector/sca/test_restore_maven_dependencies.py new file mode 100644 index 00000000..fc49cb91 --- /dev/null +++ b/tests/cli/files_collector/sca/test_restore_maven_dependencies.py @@ -0,0 +1,105 @@ +import json +from unittest.mock import MagicMock, patch + +from cycode.cli.files_collector.sca.maven.restore_maven_dependencies import ( + RestoreMavenDependencies, + _has_dependency_graph, +) +from cycode.cli.models import Document + + +class TestHasDependencyGraph: + def test_returns_false_when_content_is_none(self) -> None: + assert _has_dependency_graph(None) is False + + def test_returns_false_when_content_is_empty_string(self) -> None: + assert _has_dependency_graph('') is False + + def test_returns_false_when_dependencies_section_is_missing(self) -> None: + content = json.dumps({'components': [{'name': 'foo'}]}) + assert _has_dependency_graph(content) is False + + def test_returns_false_when_all_dependencies_have_empty_depends_on(self) -> None: + content = json.dumps({'dependencies': [{'ref': 'pkg:maven/foo/bar@1.0', 'dependsOn': []}]}) + assert _has_dependency_graph(content) is False + + def test_returns_false_when_dependencies_list_is_empty(self) -> None: + content = json.dumps({'dependencies': []}) + assert _has_dependency_graph(content) is False + + def test_returns_true_when_at_least_one_dependency_has_depends_on(self) -> None: + content = json.dumps( + { + 'dependencies': [ + {'ref': 'pkg:maven/com.example/root@1.0', 'dependsOn': ['pkg:maven/io.netty/netty-all@4.1.0']}, + {'ref': 'pkg:maven/io.netty/netty-all@4.1.0', 'dependsOn': []}, + ] + } + ) + assert _has_dependency_graph(content) is True + + def test_returns_false_when_content_is_invalid_json(self) -> None: + assert _has_dependency_graph('not valid json {{{') is False + + +class TestRestoreMavenDependenciesFallback: + def _make_instance(self) -> RestoreMavenDependencies: + ctx = MagicMock() + ctx.obj = {} + return RestoreMavenDependencies(ctx=ctx, is_git_diff=False, command_timeout=60) + + def test_falls_back_to_secondary_command_when_bom_has_no_dependency_graph(self) -> None: + instance = self._make_instance() + document = MagicMock(spec=Document) + document.content = 'some content' + + bom_doc = MagicMock(spec=Document) + bom_doc.content = json.dumps({'dependencies': []}) + fallback_doc = MagicMock(spec=Document) + fallback_doc.content = '[INFO] com.example:root:jar:1.0\n+- io.netty:netty-all:jar:4.1.0' + + with ( + patch.object(instance, 'get_manifest_file_path', return_value='/project/pom.xml'), + patch( + 'cycode.cli.files_collector.sca.maven.restore_maven_dependencies.BaseRestoreDependencies.try_restore_dependencies', + return_value=bom_doc, + ), + patch.object(instance, 'restore_from_secondary_command', return_value=fallback_doc) as mock_fallback, + ): + result = instance.try_restore_dependencies(document) + + mock_fallback.assert_called_once_with(document, '/project/pom.xml') + assert result is fallback_doc + + def test_returns_bom_document_when_dependency_graph_is_present(self) -> None: + instance = self._make_instance() + document = MagicMock(spec=Document) + document.content = 'some content' + + bom_doc = MagicMock(spec=Document) + bom_doc.content = json.dumps( + { + 'dependencies': [ + {'ref': 'pkg:maven/com.example/root@1.0', 'dependsOn': ['pkg:maven/io.netty/netty@4.1.0']} + ] + } + ) + + with ( + patch.object(instance, 'get_manifest_file_path', return_value='/project/pom.xml'), + patch( + 'cycode.cli.files_collector.sca.maven.restore_maven_dependencies.BaseRestoreDependencies.try_restore_dependencies', + return_value=bom_doc, + ), + patch.object(instance, 'restore_from_secondary_command') as mock_fallback, + ): + result = instance.try_restore_dependencies(document) + + mock_fallback.assert_not_called() + assert result is bom_doc + + def test_uses_plugin_version_2_9_1(self) -> None: + instance = self._make_instance() + commands = instance.get_commands('/path/to/pom.xml') + assert len(commands) == 1 + assert 'org.cyclonedx:cyclonedx-maven-plugin:2.9.1:makeAggregateBom' in commands[0] From db56b13b9c37db1d2277a1c41d2a2248aa2cb4bb Mon Sep 17 00:00:00 2001 From: omerr-cycode Date: Tue, 19 May 2026 16:26:12 +0300 Subject: [PATCH 295/347] CM-64439 updated READ for MCP with certificates (#457) --- README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/README.md b/README.md index 2604bb0f..d48c4fcc 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ This guide walks you through both installation and usage. 2. [Available Options](#available-options) 3. [MCP Tools](#mcp-tools) 4. [Usage Examples](#usage-examples) + 5. [Advanced Configuration](#advanced-configuration) 5. [Platform Command](#platform-command-beta) 1. [Discovering Commands](#discovering-commands) 2. [Examples](#platform-examples) @@ -559,6 +560,38 @@ cycode mcp -t streamable-http -H 127.0.0.2 -p 9000 & } ``` +### Advanced Configuration +##### Custom Certificates and Timeouts (Proxy Environments) + +If your organization uses a corporate proxy or a custom CA bundle for HTTPS inspection, you need to tell Cycode CLI (and the underlying Python TLS stack) where to find the trusted certificate bundle. You can also increase the MCP tool call timeout if scans are being cut short. + +| Environment Variable | Description | +|----------------------|-------------| +| `REQUESTS_CA_BUNDLE` | Path to a custom CA bundle file (`.pem` or `.crt`). Used by the `requests` library for all HTTPS calls made by Cycode CLI. | +| `SSL_CERT_FILE` | Path to a custom CA bundle file. Used by Python's low-level `ssl` module. Set this alongside `REQUESTS_CA_BUNDLE` for full coverage. | +| `MCP_TOOL_TIMEOUT` | Timeout (in seconds) that MCP clients such as Claude and GitHub Copilot wait for a tool call to complete. Increase this if long-running scans are being cut off before they finish. | + +> [!TIP] +> Set both `REQUESTS_CA_BUNDLE` and `SSL_CERT_FILE` to the same CA bundle path. `REQUESTS_CA_BUNDLE` covers the HTTP layer; `SSL_CERT_FILE` covers the lower-level TLS layer. Using only one may still cause certificate errors in some environments. + +Example `mcp.json` configuration with custom certificates and a longer timeout: + +```json +{ + "mcpServers": { + "cycode": { + "command": "cycode", + "args": ["mcp"], + "env": { + "REQUESTS_CA_BUNDLE": "/path/to/your/corporate-ca-bundle.pem", + "SSL_CERT_FILE": "/path/to/your/corporate-ca-bundle.pem", + "MCP_TOOL_TIMEOUT": "1800" + } + } + } +} +``` + > [!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. @@ -608,6 +641,8 @@ This information can be helpful when: - Identifying authentication problems - Debugging transport-specific issues +### MCP Configuration + # Platform Command \[BETA\] From 08499d3a888ce071726ea5dc5e13ff1cffa43793 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Mon, 25 May 2026 16:11:50 +0300 Subject: [PATCH 296/347] CM-64678: refactor ai-guardrails to single-file-per-IDE abstraction (#459) Co-authored-by: Claude Opus 4.7 (1M context) --- .../cli/apps/ai_guardrails/command_utils.py | 47 +- cycode/cli/apps/ai_guardrails/consts.py | 138 +----- .../cli/apps/ai_guardrails/hooks_manager.py | 189 +++----- .../cli/apps/ai_guardrails/ides/__init__.py | 44 ++ cycode/cli/apps/ai_guardrails/ides/base.py | 156 +++++++ .../apps/ai_guardrails/ides/claude_code.py | 389 ++++++++++++++++ cycode/cli/apps/ai_guardrails/ides/cursor.py | 119 +++++ .../cli/apps/ai_guardrails/install_command.py | 37 +- .../apps/ai_guardrails/scan/claude_config.py | 159 ------- .../apps/ai_guardrails/scan/cursor_config.py | 36 -- .../cli/apps/ai_guardrails/scan/handlers.py | 135 ++---- cycode/cli/apps/ai_guardrails/scan/payload.py | 269 +---------- .../ai_guardrails/scan/response_builders.py | 135 ------ .../apps/ai_guardrails/scan/scan_command.py | 108 +++-- cycode/cli/apps/ai_guardrails/scan/types.py | 38 +- .../ai_guardrails/session_start_command.py | 92 +--- .../cli/apps/ai_guardrails/status_command.py | 29 +- .../apps/ai_guardrails/uninstall_command.py | 34 +- .../commands/ai_guardrails/ides/__init__.py | 0 .../ai_guardrails/ides/test_claude_code.py | 262 +++++++++++ .../ai_guardrails/ides/test_contract.py | 141 ++++++ .../ai_guardrails/ides/test_cursor.py | 154 +++++++ .../ai_guardrails/scan/test_handlers.py | 98 ++-- .../ai_guardrails/scan/test_payload.py | 432 ------------------ .../scan/test_response_builders.py | 148 ------ .../ai_guardrails/scan/test_scan_command.py | 10 +- .../ai_guardrails/test_claude_config.py | 54 --- .../ai_guardrails/test_command_utils.py | 60 --- .../ai_guardrails/test_hooks_manager.py | 116 ++--- .../test_session_start_command.py | 24 +- 30 files changed, 1618 insertions(+), 2035 deletions(-) create mode 100644 cycode/cli/apps/ai_guardrails/ides/__init__.py create mode 100644 cycode/cli/apps/ai_guardrails/ides/base.py create mode 100644 cycode/cli/apps/ai_guardrails/ides/claude_code.py create mode 100644 cycode/cli/apps/ai_guardrails/ides/cursor.py delete mode 100644 cycode/cli/apps/ai_guardrails/scan/claude_config.py delete mode 100644 cycode/cli/apps/ai_guardrails/scan/cursor_config.py delete mode 100644 cycode/cli/apps/ai_guardrails/scan/response_builders.py create mode 100644 tests/cli/commands/ai_guardrails/ides/__init__.py create mode 100644 tests/cli/commands/ai_guardrails/ides/test_claude_code.py create mode 100644 tests/cli/commands/ai_guardrails/ides/test_contract.py create mode 100644 tests/cli/commands/ai_guardrails/ides/test_cursor.py delete mode 100644 tests/cli/commands/ai_guardrails/scan/test_payload.py delete mode 100644 tests/cli/commands/ai_guardrails/scan/test_response_builders.py delete mode 100644 tests/cli/commands/ai_guardrails/test_claude_config.py delete mode 100644 tests/cli/commands/ai_guardrails/test_command_utils.py diff --git a/cycode/cli/apps/ai_guardrails/command_utils.py b/cycode/cli/apps/ai_guardrails/command_utils.py index edc3104a..291fabcf 100644 --- a/cycode/cli/apps/ai_guardrails/command_utils.py +++ b/cycode/cli/apps/ai_guardrails/command_utils.py @@ -7,46 +7,11 @@ 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) -> Optional[AIIDEType]: - """Validate IDE parameter, returning None for 'all'. - - Args: - ide: IDE name string (e.g., 'cursor', 'claude-code', 'all') - - Returns: - 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}, all', - 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 - """ + """Validate scope parameter.""" 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') @@ -54,15 +19,7 @@ def validate_scope(scope: str, allowed_scopes: tuple[str, ...] = ('user', 'repo' 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 - """ + """Default repo_path to cwd for 'repo' scope; leave 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 index 2895c8d1..8018fa73 100644 --- a/cycode/cli/apps/ai_guardrails/consts.py +++ b/cycode/cli/apps/ai_guardrails/consts.py @@ -1,22 +1,6 @@ -"""Constants for AI guardrails hooks management. +"""Shared constants and policy/mode enums for AI guardrails.""" -Currently supports: -- Cursor -- Claude Code -""" - -import platform -from copy import deepcopy from enum import Enum -from pathlib import Path -from typing import NamedTuple - - -class AIIDEType(str, Enum): - """Supported AI IDE types.""" - - CURSOR = 'cursor' - CLAUDE_CODE = 'claude-code' class PolicyMode(str, Enum): @@ -33,123 +17,7 @@ class InstallMode(str, Enum): BLOCK = 'block' -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' - - -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( - name='Cursor', - hooks_dir=_get_cursor_hooks_dir(), - repo_hooks_subdir='.cursor', - 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 -DEFAULT_IDE = AIIDEType.CURSOR - -# Command used in hooks +# Base CLI commands invoked from installed hooks. IDE classes append --ide flags +# (and any other suffix) on top of these. CYCODE_SCAN_PROMPT_COMMAND = 'cycode ai-guardrails scan' CYCODE_SESSION_START_COMMAND = 'cycode ai-guardrails session-start' - - -def _get_cursor_hooks_config(async_mode: bool = False) -> dict: - """Get Cursor-specific hooks configuration.""" - config = IDE_CONFIGS[AIIDEType.CURSOR] - command = f'{CYCODE_SCAN_PROMPT_COMMAND} &' if async_mode else CYCODE_SCAN_PROMPT_COMMAND - hooks = {event: [{'command': command}] for event in config.hook_events} - hooks['sessionStart'] = [{'command': f'{CYCODE_SESSION_START_COMMAND} --ide cursor'}] - - return { - 'version': 1, - 'hooks': hooks, - } - - -def _get_claude_code_hooks_config(async_mode: bool = False) -> 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' - - hook_entry = {'type': 'command', 'command': command} - if async_mode: - hook_entry['async'] = True - hook_entry['timeout'] = 20 - - return { - 'hooks': { - 'SessionStart': [ - { - 'hooks': [{'type': 'command', 'command': f'{CYCODE_SESSION_START_COMMAND} --ide claude-code'}], - } - ], - 'UserPromptSubmit': [ - { - 'hooks': [deepcopy(hook_entry)], - } - ], - 'PreToolUse': [ - { - 'matcher': 'Read', - 'hooks': [deepcopy(hook_entry)], - }, - { - 'matcher': 'mcp__.*', - 'hooks': [deepcopy(hook_entry)], - }, - ], - }, - } - - -def get_hooks_config(ide: AIIDEType, async_mode: bool = False) -> dict: - """Get the hooks configuration for a specific IDE. - - Args: - ide: The AI IDE type - async_mode: If True, hooks run asynchronously (non-blocking) - - Returns: - Dict with hooks configuration for the specified IDE - """ - if ide == AIIDEType.CLAUDE_CODE: - return _get_claude_code_hooks_config(async_mode=async_mode) - return _get_cursor_hooks_config(async_mode=async_mode) diff --git a/cycode/cli/apps/ai_guardrails/hooks_manager.py b/cycode/cli/apps/ai_guardrails/hooks_manager.py index 74c681be..1fe23bb2 100644 --- a/cycode/cli/apps/ai_guardrails/hooks_manager.py +++ b/cycode/cli/apps/ai_guardrails/hooks_manager.py @@ -1,8 +1,8 @@ -""" -Hooks manager for AI guardrails. +"""Hooks manager for AI guardrails. -Handles installation, removal, and status checking of AI IDE hooks. -Supports multiple IDEs: Cursor, Claude Code (future). +Generic install/uninstall/status logic. All IDE-specific concerns (settings +paths, hooks template shape) live on the `IDE` instance; this module is +agent-agnostic. """ import copy @@ -12,47 +12,45 @@ import yaml -from cycode.cli.apps.ai_guardrails.consts import ( - DEFAULT_IDE, - IDE_CONFIGS, - AIIDEType, - PolicyMode, - get_hooks_config, -) +from cycode.cli.apps.ai_guardrails.consts import PolicyMode +from cycode.cli.apps.ai_guardrails.ides.base import IDE from cycode.cli.apps.ai_guardrails.scan.consts import DEFAULT_POLICY, POLICY_FILE_NAME 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. +_CYCODE_COMMAND_MARKERS = ('cycode ai-guardrails',) + - 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 _is_cycode_command(command: str) -> bool: + return any(marker in command for marker in _CYCODE_COMMAND_MARKERS) + + +def is_cycode_hook_entry(entry: dict) -> bool: + """Detect Cycode hook entries in both Cursor (flat) and Claude Code (nested) shapes.""" + command = entry.get('command', '') + if _is_cycode_command(command): + return True + + for hook in entry.get('hooks', []): + if isinstance(hook, dict) and _is_cycode_command(hook.get('command', '')): + return True + + return False -def load_hooks_file(hooks_path: Path) -> Optional[dict]: - """Load existing hooks.json file.""" +def _load_hooks_file(hooks_path: Path) -> Optional[dict]: if not hooks_path.exists(): return None try: - content = hooks_path.read_text(encoding='utf-8') - return json.loads(content) + return json.loads(hooks_path.read_text(encoding='utf-8')) 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.""" +def _save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool: try: hooks_path.parent.mkdir(parents=True, exist_ok=True) hooks_path.write_text(json.dumps(hooks_config, indent=2), encoding='utf-8') @@ -62,39 +60,7 @@ def save_hooks_file(hooks_path: Path, hooks_config: dict) -> bool: return False -_CYCODE_COMMAND_MARKERS = ('cycode ai-guardrails',) - - -def _is_cycode_command(command: str) -> bool: - return any(marker in command for marker in _CYCODE_COMMAND_MARKERS) - - -def is_cycode_hook_entry(entry: dict) -> bool: - """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', '') - if _is_cycode_command(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 _is_cycode_command(hook_command): - return True - - return False - - -def _load_policy(policy_path: Path) -> dict: - """Load existing policy file merged with defaults, or return defaults if not found.""" +def _load_policy_dict(policy_path: Path) -> dict: if not policy_path.exists(): return copy.deepcopy(DEFAULT_POLICY) try: @@ -107,22 +73,13 @@ def _load_policy(policy_path: Path) -> dict: def create_policy_file(scope: str, mode: PolicyMode, repo_path: Optional[Path] = None) -> tuple[bool, str]: """Create or update the ai-guardrails.yaml policy file. - If the file already exists, only the mode field is updated. - If it doesn't exist, a new file is created from the default policy. - - Args: - scope: 'user' for user-level, 'repo' for repository-level - mode: The policy mode to set - repo_path: Repository path (required if scope is 'repo') - - Returns: - Tuple of (success, message) + If the file already exists, only the mode field is updated; otherwise a new + file is created from the default policy. """ config_dir = repo_path / '.cycode' if scope == 'repo' and repo_path else Path.home() / '.cycode' policy_path = config_dir / POLICY_FILE_NAME - policy = _load_policy(policy_path) - + policy = _load_policy_dict(policy_path) policy['mode'] = mode.value try: @@ -135,35 +92,21 @@ def create_policy_file(scope: str, mode: PolicyMode, repo_path: Optional[Path] = def install_hooks( + ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None, - ide: AIIDEType = DEFAULT_IDE, report_mode: bool = False, ) -> 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) - report_mode: If True, install hooks in async mode (non-blocking) + """Install Cycode AI guardrails hooks for ``ide``.""" + hooks_path = ide.settings_path(scope, repo_path) - 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 = _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, async_mode=report_mode) + rendered = ide.render_hooks_config(async_mode=report_mode) - # Add/update Cycode hooks - for event, entries in hooks_config['hooks'].items(): + for event, entries in rendered['hooks'].items(): existing['hooks'].setdefault(event, []) # Remove any existing Cycode entries for this event @@ -173,47 +116,31 @@ def install_hooks( for entry in entries: existing['hooks'][event].append(entry) - # Save - if save_hooks_file(hooks_path, existing): + 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) +def uninstall_hooks(ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None) -> tuple[bool, str]: + """Remove Cycode AI guardrails hooks for ``ide``.""" + hooks_path = ide.settings_path(scope, repo_path) - existing = load_hooks_file(hooks_path) + 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() @@ -222,48 +149,35 @@ def uninstall_hooks( 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): + 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) +def get_hooks_status(ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None) -> dict: + """Return installation status of Cycode hooks for ``ide``.""" + hooks_path = ide.settings_path(scope, repo_path) - status = { + status: dict = { 'scope': scope, - 'ide': ide.value, - 'ide_name': IDE_CONFIGS[ide].name, + 'ide': ide.name, + 'ide_name': ide.display_name, 'hooks_path': str(hooks_path), 'file_exists': hooks_path.exists(), 'cycode_installed': False, 'hooks': {}, } - existing = load_hooks_file(hooks_path) + 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: - # Handle event:matcher format + for event in ide.hook_events: + # ':' filters entries to a specific tool/matcher. 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, []) @@ -278,5 +192,4 @@ def get_hooks_status(scope: str = 'user', repo_path: Optional[Path] = None, ide: } status['cycode_installed'] = has_cycode_hooks - return status diff --git a/cycode/cli/apps/ai_guardrails/ides/__init__.py b/cycode/cli/apps/ai_guardrails/ides/__init__.py new file mode 100644 index 00000000..92859701 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/__init__.py @@ -0,0 +1,44 @@ +"""Registry of supported AI guardrails IDE integrations. + +Adding a new IDE: create `ides/.py` with a subclass of `IDE`, import it +here, and include an instance in the `IDES` tuple. Nothing else in the package +needs to change. +""" + +import typer + +from cycode.cli.apps.ai_guardrails.ides.base import IDE +from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode +from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor + +# Single source of truth: name → singleton instance. +# `--ide` choices and install/uninstall/status iteration both derive from this. +IDES: dict[str, IDE] = {ide.name: ide for ide in (Cursor(), ClaudeCode())} + +# Default IDE used when `--ide` is omitted. Kept here so the value is colocated +# with the registry; no module outside `ides/` needs to know which IDE wins. +DEFAULT_IDE_NAME = 'cursor' + + +def get_ide(name: str) -> IDE: + """Look up the IDE integration registered under ``name``. + + Raises ``typer.BadParameter`` when the name is unknown — surfaces as a + user-friendly CLI error rather than a KeyError stack trace. + """ + ide = IDES.get(name.lower()) + if ide is None: + valid = ', '.join(IDES.keys()) + raise typer.BadParameter(f'Unknown IDE "{name}". Supported: {valid}.') + return ide + + +def resolve_ides(name: str) -> list[IDE]: + """Resolve an ``--ide`` argument to one or all IDE instances. + + ``"all"`` returns every registered IDE; anything else returns a single + matching IDE (raising ``typer.BadParameter`` for unknown names). + """ + if name.lower() == 'all': + return list(IDES.values()) + return [get_ide(name)] diff --git a/cycode/cli/apps/ai_guardrails/ides/base.py b/cycode/cli/apps/ai_guardrails/ides/base.py new file mode 100644 index 00000000..55d5fb05 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/base.py @@ -0,0 +1,156 @@ +"""Base abstractions for AI guardrails IDE integrations. + +Each AI IDE (Cursor, Claude Code, …) is represented by a subclass of `IDE` +that consolidates every IDE-specific concern in a single module: settings file +paths, hooks template rendering, payload parsing, response building, and any +IDE-specific session-context lookup. + +Adding a new IDE is a matter of: + 1. Subclassing `IDE` and implementing the abstract methods. + 2. Registering the instance in `cycode/cli/apps/ai_guardrails/ides/__init__.py`. + +The `HookDecision` dataclass is the canonical, IDE-agnostic return type for +event handlers; `IDE.build_hook_response` translates it into the IDE-specific +JSON response shape that the IDE expects on stdout. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import ClassVar, Optional + +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + + +class DecisionAction(str, Enum): + """Canonical decision action returned by event handlers.""" + + ALLOW = 'allow' + DENY = 'deny' + ASK = 'ask' + + +@dataclass(frozen=True) +class HookDecision: + """Canonical, IDE-agnostic decision returned by event handlers. + + Carries the event type so `IDE.build_hook_response` can pick the right + IDE-specific response shape (Cursor's "permission" style for tool events + vs. "continue" style for prompts; Claude Code's "hookSpecificOutput" + vs. "decision: block"). + """ + + action: DecisionAction + event_type: AiHookEventType + user_message: Optional[str] = None + agent_message: Optional[str] = None + + @classmethod + def allow(cls, event_type: AiHookEventType) -> 'HookDecision': + return cls(action=DecisionAction.ALLOW, event_type=event_type) + + @classmethod + def deny( + cls, event_type: AiHookEventType, user_message: str, agent_message: Optional[str] = None + ) -> 'HookDecision': + return cls( + action=DecisionAction.DENY, + event_type=event_type, + user_message=user_message, + agent_message=agent_message, + ) + + @classmethod + def ask(cls, event_type: AiHookEventType, user_message: str, agent_message: Optional[str] = None) -> 'HookDecision': + return cls( + action=DecisionAction.ASK, + event_type=event_type, + user_message=user_message, + agent_message=agent_message, + ) + + +class IDE(ABC): + """Per-IDE integration. Owns every IDE-specific concern in a single module. + + Subclasses declare identity via class attributes and implement the abstract + methods. Defaults are provided for `get_user_email` and `get_session_context` + so IDEs without those capabilities (e.g. no plugin system, no local + account file) can skip them. + """ + + # CLI value passed to --ide (e.g. 'cursor', 'claude-code'). + name: ClassVar[str] + # Human-friendly name for output ('Cursor', 'Claude Code'). + display_name: ClassVar[str] + # Event names for status display. Use ':' for IDEs that + # qualify a single hook by a sub-matcher (e.g. Claude Code's PreToolUse:Read). + hook_events: ClassVar[list[str]] + + # --- install / status --- + + @abstractmethod + def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: + """Return the hooks/settings file path for the given scope. + + `scope` is 'user' or 'repo'. `repo_path` is required when scope == 'repo'. + """ + + @abstractmethod + def render_hooks_config(self, async_mode: bool = False) -> dict: + """Return the settings blob to merge into the IDE's settings file. + + Shape is IDE-specific (Cursor uses a flat ``{event: [{command}]}`` dict; + Claude Code uses a nested ``{event: [{hooks: [{type, command}]}]}`` + dict). Both share the outer ``{"hooks": ...}`` wrapper so + ``hooks_manager`` can treat them uniformly. + """ + + # --- runtime scan --- + + @abstractmethod + def matches_payload(self, raw_payload: dict) -> bool: + """Return True if ``raw_payload`` originated from this IDE. + + Prevents double-processing when an IDE forwards another IDE's hook + event (e.g. Cursor reading Claude Code hooks from ~/.claude/settings.json). + """ + + @abstractmethod + def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: + """Normalize a raw stdin payload into the canonical ``AIHookPayload``.""" + + @abstractmethod + def build_hook_response(self, decision: HookDecision) -> dict: + """Translate a canonical ``HookDecision`` into the IDE-specific JSON. + + The result is what ``scan_command`` writes to stdout for the IDE to + act on. + """ + + # --- session lifecycle (optional; sensible defaults) --- + + def build_session_payload(self, raw_payload: dict) -> AIHookPayload: + """Build a session-start payload from the raw stdin payload. + + Default: a minimal payload tagged with this IDE's ``name``. IDEs + that need to enrich with transcript/version info should override. + """ + return AIHookPayload(ide_provider=self.name) + + def get_user_email(self) -> Optional[str]: + """Best-effort read of the user's email from IDE-specific config. + + Default: None. Override if the IDE stores a usable account locally. + """ + return None + + def get_session_context(self) -> tuple[dict, dict]: + """Return ``(mcp_servers, enabled_plugins)`` for session-context reporting. + + Default: empty dicts (no plugin system, no discoverable MCP config). + Override to surface MCP/plugin inventory. + """ + return {}, {} diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py new file mode 100644 index 00000000..519914b9 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -0,0 +1,389 @@ +"""Claude Code IDE integration for AI guardrails.""" + +import json +from collections.abc import Iterator +from copy import deepcopy +from pathlib import Path +from typing import ClassVar, Optional + +from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Claude Code') + +_CLAUDE_CODE_EVENT_NAMES = frozenset({'UserPromptSubmit', 'PreToolUse'}) + +_USER_HOOKS_DIR = Path.home() / '.claude' +_HOOKS_FILE_NAME = 'settings.json' +_REPO_SUBDIR = '.claude' +_HOOK_EVENTS = ['UserPromptSubmit', 'PreToolUse:Read', 'PreToolUse:mcp'] + +_CLAUDE_CONFIG_PATH = Path.home() / '.claude.json' +_CLAUDE_SETTINGS_PATH = Path.home() / '.claude' / 'settings.json' + +_SCAN_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide claude-code' +_SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide claude-code' + + +# --- transcript JSONL parsing ------------------------------------------------- + + +def _reverse_readline(path: Path, buf_size: int = 8192) -> Iterator[str]: + """Yield lines of `path` from end to start without loading the file. + + The Claude Code transcript can be very large; reading from the tail keeps + memory bounded since we only care about the most recent entries. + """ + with path.open('rb') as f: + f.seek(0, 2) + file_size = f.tell() + if file_size == 0: + return + + remaining = file_size + buffer = b'' + + while remaining > 0: + read_size = min(buf_size, remaining) + remaining -= read_size + f.seek(remaining) + chunk = f.read(read_size) + buffer = chunk + buffer + + while b'\n' in buffer: + newline_pos = buffer.rfind(b'\n') + if newline_pos == len(buffer) - 1: + newline_pos = buffer.rfind(b'\n', 0, newline_pos) + if newline_pos == -1: + break + line = buffer[newline_pos + 1 :] + buffer = buffer[: newline_pos + 1] + if line.strip(): + yield line.decode('utf-8', errors='replace') + + 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, generation_id)`` from a transcript. + + The transcript is a JSONL file scanned from end → start so the most recent + entries are read first. Any field may come back ``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 + + +# --- ~/.claude.json + ~/.claude/settings.json parsing ------------------------- + + +def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse `~/.claude.json`. Returns None if missing/invalid.""" + path = config_path or _CLAUDE_CONFIG_PATH + if not path.exists(): + logger.debug('Claude config file not found', extra={'path': str(path)}) + return None + try: + return json.loads(path.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load Claude config file', exc_info=e) + return None + + +def _email_from_config(config: dict) -> Optional[str]: + """Read ``oauthAccount.emailAddress`` from a parsed Claude config.""" + return config.get('oauthAccount', {}).get('emailAddress') + + +def get_mcp_servers(config: dict) -> Optional[dict]: + """Read ``mcpServers`` from a parsed Claude config.""" + return config.get('mcpServers') + + +def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse `~/.claude/settings.json`. Returns None if missing/invalid.""" + path = settings_path or _CLAUDE_SETTINGS_PATH + if not path.exists(): + logger.debug('Claude settings file not found', extra={'path': str(path)}) + return None + try: + return json.loads(path.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load Claude settings file', exc_info=e) + return None + + +def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]: + """Resolve filesystem path for a directory-type marketplace.""" + source = marketplace.get('source', {}) + if source.get('source') != 'directory': + return None + raw = source.get('path') + if not raw: + return None + path = Path(raw) + return path if path.is_dir() else None + + +def _load_plugin_json_file(plugin_path: Path, relative_path: str) -> Optional[dict]: + """Load and parse a JSON file inside a plugin directory. + + Returns None if the file is missing, unreadable, or has invalid JSON. + """ + target = plugin_path / relative_path + if not target.exists(): + return None + try: + return json.loads(target.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load plugin file', extra={'path': str(target)}, exc_info=e) + return None + + +def resolve_plugins(settings: dict) -> tuple[dict, dict]: + """Resolve enabled plugins to their MCP servers and metadata. + + Walks ``enabledPlugins`` from claude settings, resolves each plugin's + marketplace directory via ``extraKnownMarketplaces``, and reads: + - ``/.mcp.json`` for MCP servers (merged into a flat dict) + - ``/.claude-plugin/plugin.json`` for metadata (name, version, description) + + Returns ``(merged_mcp_servers, enriched_plugins)``. + """ + enabled = settings.get('enabledPlugins') or {} + marketplaces = settings.get('extraKnownMarketplaces') or {} + merged_mcp: dict = {} + enriched: dict = {} + + for plugin_key, is_enabled in enabled.items(): + if not is_enabled: + continue + + entry: dict = {'enabled': True} + enriched[plugin_key] = entry + + if '@' not in plugin_key: + continue + + _plugin_name, marketplace_name = plugin_key.split('@', 1) + marketplace = marketplaces.get(marketplace_name) + if not marketplace: + continue + + plugin_path = _resolve_marketplace_path(marketplace) + if plugin_path is None: + continue + + metadata = _load_plugin_json_file(plugin_path, '.claude-plugin/plugin.json') or {} + for field in ('name', 'version', 'description'): + if field in metadata: + entry[field] = metadata[field] + + mcp_config = _load_plugin_json_file(plugin_path, '.mcp.json') or {} + plugin_server_names = [] + for server_name, server_cfg in (mcp_config.get('mcpServers') or {}).items(): + merged_mcp[server_name] = server_cfg + plugin_server_names.append(server_name) + if plugin_server_names: + entry['mcp_server_names'] = plugin_server_names + + return merged_mcp, enriched + + +# --- IDE integration ---------------------------------------------------------- + + +class ClaudeCode(IDE): + name: ClassVar[str] = 'claude-code' + display_name: ClassVar[str] = 'Claude Code' + hook_events: ClassVar[list[str]] = list(_HOOK_EVENTS) + + def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: + if scope == 'repo' and repo_path: + return repo_path / _REPO_SUBDIR / _HOOKS_FILE_NAME + return _USER_HOOKS_DIR / _HOOKS_FILE_NAME + + def render_hooks_config(self, async_mode: bool = False) -> dict: + # Claude Code uses a nested hook structure with optional async/timeout. + hook_entry: dict = {'type': 'command', 'command': _SCAN_COMMAND} + if async_mode: + hook_entry['async'] = True + hook_entry['timeout'] = 20 + + return { + 'hooks': { + 'SessionStart': [ + { + 'hooks': [{'type': 'command', 'command': _SESSION_START_COMMAND}], + } + ], + 'UserPromptSubmit': [ + { + 'hooks': [deepcopy(hook_entry)], + } + ], + 'PreToolUse': [ + { + 'matcher': 'Read', + 'hooks': [deepcopy(hook_entry)], + }, + { + 'matcher': 'mcp__.*', + 'hooks': [deepcopy(hook_entry)], + }, + ], + }, + } + + def matches_payload(self, raw_payload: dict) -> bool: + return raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES + + def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: + hook_event_name = raw_payload.get('hook_event_name', '') + tool_name = raw_payload.get('tool_name', '') + tool_input = raw_payload.get('tool_input') + + if hook_event_name == 'UserPromptSubmit': + canonical_event: AiHookEventType | str = AiHookEventType.PROMPT + elif hook_event_name == 'PreToolUse': + canonical_event = AiHookEventType.FILE_READ if tool_name == 'Read' else AiHookEventType.MCP_EXECUTION + else: + canonical_event = hook_event_name + + # Extract file_path from tool_input for the 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 + + # MCP 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] + + ide_version, model, generation_id = extract_from_claude_transcript(raw_payload.get('transcript_path')) + + config = load_claude_config() + ide_user_email = _email_from_config(config) if config else None + + return AIHookPayload( + event_name=canonical_event, + conversation_id=raw_payload.get('session_id'), + generation_id=generation_id, + ide_user_email=ide_user_email, + model=model, + ide_provider=self.name, + ide_version=ide_version, + prompt=raw_payload.get('prompt', ''), + file_path=file_path, + mcp_server_name=mcp_server_name, + mcp_tool_name=mcp_tool_name, + mcp_arguments=mcp_arguments, + ) + + def build_hook_response(self, decision: HookDecision) -> dict: + if decision.event_type == AiHookEventType.PROMPT: + if decision.action == DecisionAction.ALLOW: + return {} + # Both DENY and (unexpected) ASK on prompts collapse to a block. + return {'decision': 'block', 'reason': decision.user_message or ''} + + # FILE_READ / MCP_EXECUTION → hookSpecificOutput shape. + if decision.action == DecisionAction.ALLOW: + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'allow', + } + } + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': decision.action.value, # 'deny' or 'ask' + 'permissionDecisionReason': decision.user_message or '', + } + } + + def build_session_payload(self, raw_payload: dict) -> AIHookPayload: + config = load_claude_config() + ide_user_email = _email_from_config(config) if config else None + ide_version, _, _ = extract_from_claude_transcript(raw_payload.get('transcript_path')) + + return AIHookPayload( + conversation_id=raw_payload.get('session_id'), + ide_user_email=ide_user_email, + model=raw_payload.get('model'), + ide_provider=self.name, + ide_version=ide_version, + source=raw_payload.get('source'), + ) + + def get_user_email(self) -> Optional[str]: + config = load_claude_config() + return _email_from_config(config) if config else None + + def get_session_context(self) -> tuple[dict, dict]: + config = load_claude_config() + mcp_servers: dict = dict(get_mcp_servers(config) or {}) if config else {} + + settings = load_claude_settings() + if settings: + plugin_mcp, enriched_plugins = resolve_plugins(settings) + mcp_servers.update(plugin_mcp) + else: + enriched_plugins = {} + + return mcp_servers, enriched_plugins diff --git a/cycode/cli/apps/ai_guardrails/ides/cursor.py b/cycode/cli/apps/ai_guardrails/ides/cursor.py new file mode 100644 index 00000000..4f6be1eb --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/cursor.py @@ -0,0 +1,119 @@ +"""Cursor IDE integration for AI guardrails.""" + +import json +import platform +from pathlib import Path +from typing import ClassVar, Optional + +from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Cursor') + +_CURSOR_EVENT_MAPPING: dict[str, AiHookEventType] = { + 'beforeSubmitPrompt': AiHookEventType.PROMPT, + 'beforeReadFile': AiHookEventType.FILE_READ, + 'beforeMCPExecution': AiHookEventType.MCP_EXECUTION, +} + +_HOOKS_FILE_NAME = 'hooks.json' +_REPO_SUBDIR = '.cursor' +_MCP_CONFIG_FILENAME = 'mcp.json' + +# Cursor was the original default IDE — its scan command omits --ide to stay +# byte-identical with already-installed hooks.json files. Session-start is +# always explicit because it was introduced after Claude Code support. +_SCAN_COMMAND = CYCODE_SCAN_PROMPT_COMMAND +_SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide cursor' + + +def _user_hooks_dir() -> Path: + """Per-platform Cursor user-scope settings directory.""" + if platform.system() == 'Darwin': + return Path.home() / '.cursor' + if platform.system() == 'Windows': + return Path.home() / 'AppData' / 'Roaming' / 'Cursor' + return Path.home() / '.config' / 'Cursor' + + +def _load_cursor_mcp_config(config_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse `~/.cursor/mcp.json`. Returns None if missing/invalid.""" + path = config_path or (Path.home() / '.cursor' / _MCP_CONFIG_FILENAME) + if not path.exists(): + logger.debug('Cursor MCP config file not found', extra={'path': str(path)}) + return None + try: + return json.loads(path.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load Cursor MCP config file', exc_info=e) + return None + + +class Cursor(IDE): + name: ClassVar[str] = 'cursor' + display_name: ClassVar[str] = 'Cursor' + hook_events: ClassVar[list[str]] = list(_CURSOR_EVENT_MAPPING) + + def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: + if scope == 'repo' and repo_path: + return repo_path / _REPO_SUBDIR / _HOOKS_FILE_NAME + return _user_hooks_dir() / _HOOKS_FILE_NAME + + def render_hooks_config(self, async_mode: bool = False) -> dict: + command = f'{_SCAN_COMMAND} &' if async_mode else _SCAN_COMMAND + hooks = {event: [{'command': command}] for event in self.hook_events} + hooks['sessionStart'] = [{'command': _SESSION_START_COMMAND}] + return {'version': 1, 'hooks': hooks} + + def matches_payload(self, raw_payload: dict) -> bool: + return raw_payload.get('hook_event_name', '') in _CURSOR_EVENT_MAPPING + + def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: + cursor_event_name = raw_payload.get('hook_event_name', '') + canonical_event = _CURSOR_EVENT_MAPPING.get(cursor_event_name, cursor_event_name) + return AIHookPayload( + event_name=canonical_event, + conversation_id=raw_payload.get('conversation_id'), + generation_id=raw_payload.get('generation_id'), + ide_user_email=raw_payload.get('user_email'), + model=raw_payload.get('model'), + ide_provider=self.name, + ide_version=raw_payload.get('cursor_version'), + prompt=raw_payload.get('prompt', ''), + file_path=raw_payload.get('file_path') or raw_payload.get('path'), + mcp_server_name=raw_payload.get('command'), + mcp_tool_name=raw_payload.get('tool_name') or raw_payload.get('tool'), + mcp_arguments=(raw_payload.get('arguments') or raw_payload.get('tool_input') or raw_payload.get('input')), + ) + + def build_hook_response(self, decision: HookDecision) -> dict: + if decision.event_type == AiHookEventType.PROMPT: + if decision.action == DecisionAction.ALLOW: + return {'continue': True} + return {'continue': False, 'user_message': decision.user_message or ''} + + # FILE_READ / MCP_EXECUTION → permission shape + if decision.action == DecisionAction.ALLOW: + return {'permission': 'allow'} + return { + 'permission': decision.action.value, # 'deny' or 'ask' + 'user_message': decision.user_message or '', + 'agent_message': decision.agent_message or '', + } + + def build_session_payload(self, raw_payload: dict) -> AIHookPayload: + return AIHookPayload( + conversation_id=raw_payload.get('conversation_id'), + ide_user_email=raw_payload.get('user_email'), + model=raw_payload.get('model'), + ide_provider=self.name, + ide_version=raw_payload.get('cursor_version'), + ) + + def get_session_context(self) -> tuple[dict, dict]: + config = _load_cursor_mcp_config() + mcp_servers = dict((config or {}).get('mcpServers') or {}) if config else {} + return mcp_servers, {} diff --git a/cycode/cli/apps/ai_guardrails/install_command.py b/cycode/cli/apps/ai_guardrails/install_command.py index a92a978f..0ee5aacb 100644 --- a/cycode/cli/apps/ai_guardrails/install_command.py +++ b/cycode/cli/apps/ai_guardrails/install_command.py @@ -5,14 +5,10 @@ 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, AIIDEType, InstallMode, PolicyMode +from cycode.cli.apps.ai_guardrails.command_utils import console, resolve_repo_path, validate_scope +from cycode.cli.apps.ai_guardrails.consts import InstallMode, PolicyMode from cycode.cli.apps.ai_guardrails.hooks_manager import create_policy_file, install_hooks +from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, IDES, resolve_ides def install_command( @@ -29,9 +25,9 @@ def install_command( str, typer.Option( '--ide', - help='IDE to install hooks for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.', + help=f'IDE to install hooks for ({", ".join(IDES)}, or "all" for every supported IDE).', ), - ] = AIIDEType.CURSOR.value, + ] = DEFAULT_IDE_NAME, repo_path: Annotated[ Optional[Path], typer.Option( @@ -55,35 +51,30 @@ def install_command( ) -> 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. + Configures the specified IDE to use Cycode for scanning prompts, file reads, + and MCP tool calls for secrets before they reach the AI model. Examples: cycode ai-guardrails install # Install in report mode (default) cycode ai-guardrails install --mode block # Install in block mode 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 + cycode ai-guardrails install --ide claude-code # Install for a specific IDE + cycode ai-guardrails install --ide all # Install for every supported IDE """ - # Validate inputs validate_scope(scope) repo_path = resolve_repo_path(scope, repo_path) - ide_type = validate_and_parse_ide(ide) + ides_to_install = resolve_ides(ide) - ides_to_install: list[AIIDEType] = list(AIIDEType) if ide_type is None else [ide_type] + report_mode = mode == InstallMode.REPORT results: list[tuple[str, bool, str]] = [] for current_ide in ides_to_install: - ide_name = IDE_CONFIGS[current_ide].name - report_mode = mode == InstallMode.REPORT - success, message = install_hooks(scope, repo_path, ide=current_ide, report_mode=report_mode) - results.append((ide_name, success, message)) + success, message = install_hooks(current_ide, scope, repo_path, report_mode=report_mode) + results.append((current_ide.display_name, success, message)) - # Report results for each IDE any_success = False all_success = True - for _ide_name, success, message in results: + for _name, success, message in results: if success: console.print(f'[green]✓[/] {message}') any_success = True diff --git a/cycode/cli/apps/ai_guardrails/scan/claude_config.py b/cycode/cli/apps/ai_guardrails/scan/claude_config.py deleted file mode 100644 index 4b547427..00000000 --- a/cycode/cli/apps/ai_guardrails/scan/claude_config.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Reader for ~/.claude.json configuration file. - -Extracts user email from the Claude Code global config file -for use in AI guardrails scan enrichment. -""" - -import json -from pathlib import Path -from typing import Optional - -from cycode.logger import get_logger - -logger = get_logger('AI Guardrails Claude Config') - -_CLAUDE_CONFIG_PATH = Path.home() / '.claude.json' -_CLAUDE_SETTINGS_PATH = Path.home() / '.claude' / 'settings.json' - - -def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]: - """Load and parse ~/.claude.json. - - Args: - config_path: Override path for testing. Defaults to ~/.claude.json. - - Returns: - Parsed dict or None if file is missing or invalid. - """ - path = config_path or _CLAUDE_CONFIG_PATH - if not path.exists(): - logger.debug('Claude config file not found', extra={'path': str(path)}) - return None - try: - content = path.read_text(encoding='utf-8') - return json.loads(content) - except Exception as e: - logger.debug('Failed to load Claude config file', exc_info=e) - return None - - -def get_user_email(config: dict) -> Optional[str]: - """Extract user email from Claude config. - - Reads oauthAccount.emailAddress from the config dict. - """ - return config.get('oauthAccount', {}).get('emailAddress') - - -def get_mcp_servers(config: dict) -> Optional[dict]: - """Extract MCP servers from Claude config. - - Reads mcpServers from the config dict. - """ - return config.get('mcpServers') - - -def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict]: - """Load and parse ~/.claude/settings.json. - - Args: - settings_path: Override path for testing. Defaults to ~/.claude/settings.json. - - Returns: - Parsed dict or None if file is missing or invalid. - """ - path = settings_path or _CLAUDE_SETTINGS_PATH - if not path.exists(): - logger.debug('Claude settings file not found', extra={'path': str(path)}) - return None - try: - content = path.read_text(encoding='utf-8') - return json.loads(content) - except Exception as e: - logger.debug('Failed to load Claude settings file', exc_info=e) - return None - - -def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]: - """ - Resolve filesystem path for a directory-type marketplace. - """ - source = marketplace.get('source', {}) - if source.get('source') != 'directory': - return None - raw = source.get('path') - if not raw: - return None - path = Path(raw) - return path if path.is_dir() else None - - -def _load_plugin_json_file(plugin_path: Path, relative_path: str) -> Optional[dict]: - """Load and parse a JSON file inside a plugin directory. - - Returns None if the file is missing, unreadable, or has invalid JSON. - """ - target = plugin_path / relative_path - if not target.exists(): - return None - try: - return json.loads(target.read_text(encoding='utf-8')) - except Exception as e: - logger.debug('Failed to load plugin file', extra={'path': str(target)}, exc_info=e) - return None - - -def resolve_plugins(settings: dict) -> tuple[dict, dict]: - """Resolve enabled plugins to their MCP servers and metadata. - - Walks enabledPlugins from claude settings, resolves each plugin's 'marketplace' directory - via the 'extraKnownMarketplaces' field, and reads: - - /.mcp.json for MCP servers (merged into a flat dict) - - /.claude-plugin/plugin.json for metadata (name, version, description) - - Args: - settings: Parsed ~/.claude/settings.json dict. - - Returns: - Tuple of (merged_mcp_servers, enriched_plugins): - - merged_mcp_servers: {server_name: server_config, ...} - - enriched_plugins: {plugin_key: {"enabled": True, "name": ..., ...}, ...} - """ - enabled = settings.get('enabledPlugins') or {} - marketplaces = settings.get('extraKnownMarketplaces') or {} - merged_mcp: dict = {} - enriched: dict = {} - - for plugin_key, is_enabled in enabled.items(): - if not is_enabled: - continue - - entry: dict = {'enabled': True} - enriched[plugin_key] = entry - - if '@' not in plugin_key: - continue - - _plugin_name, marketplace_name = plugin_key.split('@', 1) - marketplace = marketplaces.get(marketplace_name) - if not marketplace: - continue - - plugin_path = _resolve_marketplace_path(marketplace) - if plugin_path is None: - continue - - metadata = _load_plugin_json_file(plugin_path, '.claude-plugin/plugin.json') or {} - for field in ('name', 'version', 'description'): - if field in metadata: - entry[field] = metadata[field] - - mcp_config = _load_plugin_json_file(plugin_path, '.mcp.json') or {} - plugin_server_names = [] - for server_name, server_cfg in (mcp_config.get('mcpServers') or {}).items(): - merged_mcp[server_name] = server_cfg - plugin_server_names.append(server_name) - if plugin_server_names: - entry['mcp_server_names'] = plugin_server_names - - return merged_mcp, enriched diff --git a/cycode/cli/apps/ai_guardrails/scan/cursor_config.py b/cycode/cli/apps/ai_guardrails/scan/cursor_config.py deleted file mode 100644 index 9a174a7a..00000000 --- a/cycode/cli/apps/ai_guardrails/scan/cursor_config.py +++ /dev/null @@ -1,36 +0,0 @@ -"""Reader for ~/.cursor/mcp.json configuration file. - -Extracts MCP server definitions from the Cursor global config file -for use in AI guardrails session-context reporting. -""" - -import json -from pathlib import Path -from typing import Optional - -from cycode.logger import get_logger - -logger = get_logger('AI Guardrails Cursor Config') - -_CURSOR_MCP_CONFIG_PATH = Path.home() / '.cursor' / 'mcp.json' - - -def load_cursor_config(config_path: Optional[Path] = None) -> Optional[dict]: - """Load and parse ~/.cursor/mcp.json. - - Args: - config_path: Override path for testing. Defaults to ~/.cursor/mcp.json. - - Returns: - Parsed dict or None if file is missing or invalid. - """ - path = config_path or _CURSOR_MCP_CONFIG_PATH - if not path.exists(): - logger.debug('Cursor MCP config file not found', extra={'path': str(path)}) - return None - try: - content = path.read_text(encoding='utf-8') - return json.loads(content) - except Exception as e: - logger.debug('Failed to load Cursor MCP config file', exc_info=e) - return None diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index fa0bddee..4a56a179 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -1,8 +1,11 @@ -""" -Hook handlers for AI IDE events. +"""Hook handlers for AI IDE events. + +Each handler receives a unified payload and policy, applies the scan + policy +logic, and returns a canonical ``HookDecision``. ``scan_command`` translates +that decision into the IDE-specific JSON response via ``IDE.build_hook_response``. -Each handler receives a unified payload from an IDE, applies policy rules, -and returns a response that either allows or blocks the action. +Handlers are agent-agnostic by design — adding a new IDE doesn't require +touching any handler in this module. """ import json @@ -14,9 +17,9 @@ import typer from cycode.cli.apps.ai_guardrails.consts import PolicyMode +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision 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 @@ -30,21 +33,17 @@ logger = get_logger('AI Guardrails') -def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> dict: - """ - Handle beforeSubmitPrompt hook. +HandlerFn = Callable[[typer.Context, AIHookPayload, dict], HookDecision] - Scans prompt text for secrets before it's sent to the AI model. - Returns {"continue": False} to block, {"continue": True} to allow. - """ + +def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision: + """Scan prompt text for secrets before it's sent to the AI model.""" 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={}) if not get_policy_value(prompt_config, 'enabled', default=True): ai_client.create_event(payload, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED) - return response_builder.allow_prompt() + return HookDecision.allow(AiHookEventType.PROMPT) mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) prompt = payload.prompt or '' @@ -66,9 +65,9 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli 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) + return HookDecision.deny(AiHookEventType.PROMPT, user_message) outcome = AIHookOutcome.WARNED - return response_builder.allow_prompt() + return HookDecision.allow(AiHookEventType.PROMPT) except Exception as e: outcome = ( AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED @@ -87,21 +86,14 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli ) -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. - """ +def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision: + """Block sensitive paths and scan file content for secrets.""" 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={}) 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() + return HookDecision.allow(AiHookEventType.FILE_READ) mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) file_path = payload.file_path or '' @@ -113,20 +105,19 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: error_message = None try: - # Check path-based denylist first is_sensitive_path = is_denied_path(file_path, policy) if is_sensitive_path: block_reason = BlockReason.SENSITIVE_PATH 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( + return HookDecision.deny( + AiHookEventType.FILE_READ, user_message, 'This file path is classified as sensitive; do not read/send it to the model.', ) - # Warn mode - if content scan is enabled, emit a separate event for the + # Warn mode: if content scan is enabled, emit a separate event for the # sensitive path so the finally block can independently track the scan result. - # If content scan is disabled, a single event (from finally) is enough. outcome = AIHookOutcome.WARNED if get_policy_value(file_read_config, 'scan_content', default=True): ai_client.create_event( @@ -136,11 +127,9 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: block_reason=BlockReason.SENSITIVE_PATH, file_path=payload.file_path, ) - # Reset for the content scan result tracked by the finally block block_reason = None outcome = AIHookOutcome.ALLOWED - # 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: @@ -148,27 +137,28 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: 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( + return HookDecision.deny( + AiHookEventType.FILE_READ, 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( + return HookDecision.ask( + AiHookEventType.FILE_READ, user_message, 'Possible secrets detected; proceed with caution.', ) - # If path was sensitive but content scan found no secrets (or scan disabled), still warn if is_sensitive_path: user_message = f'Cycode flagged {file_path} as sensitive. Allow reading?' - return response_builder.ask_permission( + return HookDecision.ask( + AiHookEventType.FILE_READ, user_message, 'This file path is classified as sensitive; proceed with caution.', ) - return response_builder.allow_permission() + return HookDecision.allow(AiHookEventType.FILE_READ) except Exception as e: outcome = ( AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED @@ -188,22 +178,14 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: ) -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. - """ +def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision: + """Scan MCP tool arguments for secrets before execution.""" 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={}) 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() + return HookDecision.allow(AiHookEventType.MCP_EXECUTION) mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) tool = payload.mcp_tool_name or 'unknown' @@ -227,17 +209,19 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK: outcome = AIHookOutcome.BLOCKED user_message = f'Cycode blocked MCP tool call "{tool}". {violation_summary}' - return response_builder.deny_permission( + return HookDecision.deny( + AiHookEventType.MCP_EXECUTION, user_message, 'Do not pass secrets to tools. Use secret references (name/id) instead.', ) outcome = AIHookOutcome.WARNED - return response_builder.ask_permission( + return HookDecision.ask( + AiHookEventType.MCP_EXECUTION, f'{violation_summary} in MCP tool call "{tool}". Allow execution?', 'Possible secrets detected in tool arguments; proceed with caution.', ) - return response_builder.allow_permission() + return HookDecision.allow(AiHookEventType.MCP_EXECUTION) except Exception as e: outcome = ( AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED @@ -256,16 +240,9 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli ) -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 = { +def get_handler_for_event(event_type: str) -> Optional[HandlerFn]: + """Look up the handler for a canonical event type.""" + handlers: dict[str, HandlerFn] = { AiHookEventType.PROMPT.value: handle_before_submit_prompt, AiHookEventType.FILE_READ.value: handle_before_read_file, AiHookEventType.MCP_EXECUTION.value: handle_before_mcp_execution, @@ -275,32 +252,24 @@ def get_handler_for_event(event_type: str) -> Optional[Callable[[typer.Context, 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.obj['sync'] = True + ctx.obj['scan_type'] = ScanTypeOption.SECRET + ctx.obj['severity_threshold'] = SeverityOption.INFO 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. + """Run a scan on documents, returning (violation_summary, scan_id). - 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). + Raises on scan failure / timeout so the fail-open policy can take over. """ 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 ) @@ -324,7 +293,6 @@ def _perform_scan( 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 @@ -333,12 +301,7 @@ def _perform_scan( 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. - """ + """Scan text content for secrets using Cycode CLI.""" if not text: return None, None @@ -349,12 +312,7 @@ def _scan_text_for_secrets(ctx: typer.Context, text: str, timeout_ms: int) -> tu 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. - """ + """Scan a file path for secrets.""" if not file_path or not os.path.isfile(file_path): return None, None @@ -363,7 +321,6 @@ def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) -> with open(file_path, encoding='utf-8', errors='replace') as f: content = f.read(max_bytes) - # Get timeout from policy timeout_ms = get_policy_value(policy, 'secrets', 'timeout_ms', default=30000) timeout_seconds = timeout_ms / 1000.0 diff --git a/cycode/cli/apps/ai_guardrails/scan/payload.py b/cycode/cli/apps/ai_guardrails/scan/payload.py index d8fd4c53..19845601 100644 --- a/cycode/cli/apps/ai_guardrails/scan/payload.py +++ b/cycode/cli/apps/ai_guardrails/scan/payload.py @@ -1,275 +1,34 @@ -"""Unified payload object for AI hook events from different tools.""" +"""Canonical AI hook payload shared across IDE integrations. + +The dataclass is populated by `IDE.parse_hook_payload` (see +`cycode/cli/apps/ai_guardrails/ides/`). Per-IDE parsing logic lives on the +respective IDE class. +""" -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.consts import AIIDEType -from cycode.cli.apps.ai_guardrails.scan.claude_config import get_user_email, load_claude_config -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 class AIHookPayload: - """Unified payload object that normalizes field names from different AI tools.""" + """Unified payload that normalizes field names across IDEs.""" # Event identification - event_name: Optional[str] = None # Canonical event type (e.g., 'prompt', 'file_read', 'mcp_execution') + event_name: Optional[str] = None # Canonical event type from AiHookEventType 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 # AIIDEType value (e.g., 'cursor', 'claude-code') + ide_provider: Optional[str] = None # Matches IDE.name (e.g. 'cursor', 'claude-code') ide_version: Optional[str] = None source: 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=AIIDEType.CURSOR.value, - 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_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')) - - # Extract user email from ~/.claude.json - claude_config = load_claude_config() - ide_user_email = get_user_email(claude_config) if claude_config else None - - return cls( - event_name=canonical_event, - conversation_id=payload.get('session_id'), - generation_id=generation_id, - ide_user_email=ide_user_email, - model=model, - ide_provider=AIIDEType.CLAUDE_CODE.value, - 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.value) -> 'AIHookPayload': - """Create AIHookPayload from any tool's payload. - - Args: - payload: The raw payload from the IDE - tool: The IDE/tool name or AIIDEType enum value - - Returns: - AIHookPayload instance - - Raises: - ValueError: If the tool is not supported - """ - if tool == AIIDEType.CURSOR: - return cls.from_cursor_payload(payload) - if tool == AIIDEType.CLAUDE_CODE: - return cls.from_claude_code_payload(payload) - raise ValueError(f'Unsupported IDE/tool: {tool}') + prompt: Optional[str] = None # PROMPT events + file_path: Optional[str] = None # FILE_READ events + mcp_server_name: Optional[str] = None # MCP_EXECUTION events + mcp_tool_name: Optional[str] = None + mcp_arguments: Optional[dict] = None diff --git a/cycode/cli/apps/ai_guardrails/scan/response_builders.py b/cycode/cli/apps/ai_guardrails/scan/response_builders.py deleted file mode 100644 index ff0a6aa4..00000000 --- a/cycode/cli/apps/ai_guardrails/scan/response_builders.py +++ /dev/null @@ -1,135 +0,0 @@ -""" -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 - -from cycode.cli.apps.ai_guardrails.consts import AIIDEType - - -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} - - -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] = { - AIIDEType.CURSOR: CursorResponseBuilder(), - AIIDEType.CLAUDE_CODE: ClaudeCodeResponseBuilder(), -} - - -def get_response_builder(ide: str = AIIDEType.CURSOR.value) -> IDEResponseBuilder: - """Get the response builder for a specific IDE. - - Args: - ide: The IDE name (e.g., 'cursor', 'claude-code') or AIIDEType enum - - 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 index add2bb83..bd31d33e 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -1,26 +1,22 @@ -""" -Scan command for AI guardrails. +"""Scan command for AI guardrails IDE hooks. -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. +Reads a JSON payload from stdin, routes it through the IDE-specific parser and +the shared event handlers, then writes an IDE-specific JSON response to stdout. -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). +The handlers in ``handlers.py`` are agent-agnostic (they return +``HookDecision``); ``IDE.build_hook_response`` is the per-IDE translation step. """ import sys -from typing import Annotated +from typing import Annotated, Optional, Union import click import typer -from cycode.cli.apps.ai_guardrails.consts import AIIDEType +from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, get_ide +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision 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 @@ -31,7 +27,7 @@ def _get_auth_error_message(error: Exception) -> str: - """Get user-friendly message for authentication errors.""" + """User-friendly message for authentication errors.""" if isinstance(error, click.ClickException): # Missing credentials return f'{error.message} Please run `cycode auth` to set up your credentials.' @@ -47,6 +43,23 @@ def _get_auth_error_message(error: Exception) -> str: return 'Authentication failed. Please run `cycode auth` to set up your credentials.' +def _deny_for_event( + event_name: Optional[Union[str, AiHookEventType]], + user_message: str, + agent_message: Optional[str] = None, +) -> HookDecision: + """Build a deny decision matched to ``event_name``'s response shape. + + PROMPT events use the prompt-block shape (no agent_message). For anything + else — including unknown event names — fall back to FILE_READ since + FILE_READ and MCP_EXECUTION share the same response shape on both IDEs. + """ + if event_name == AiHookEventType.PROMPT: + return HookDecision.deny(AiHookEventType.PROMPT, user_message) + target = event_name if isinstance(event_name, AiHookEventType) else AiHookEventType.FILE_READ + return HookDecision.deny(target, user_message, agent_message) + + def _initialize_clients(ctx: typer.Context) -> None: """Initialize API clients. @@ -69,44 +82,36 @@ def scan_command( help='IDE that sent the payload (e.g., "cursor"). Defaults to cursor.', hidden=True, ), - ] = AIIDEType.CURSOR.value, + ] = DEFAULT_IDE_NAME, ) -> 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" } + Reads a JSON payload from stdin and outputs a JSON response to stdout + indicating whether to allow or block the action. """ + ide_integration = get_ide(ide) + 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()) + output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.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): + # Prevent cross-IDE processing (e.g. Cursor reading Claude Code hooks + # from ~/.claude/settings.json). + if not ide_integration.matches_payload(payload): logger.debug( 'Payload event does not match expected IDE, skipping', - extra={'hook_event_name': payload.get('hook_event_name'), 'expected_ide': tool}, + extra={'hook_event_name': payload.get('hook_event_name'), 'expected_ide': ide_integration.name}, ) - output_json(response_builder.allow_prompt()) + output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) return - unified_payload = AIHookPayload.from_payload(payload, tool=tool) + unified_payload = ide_integration.parse_hook_payload(payload) event_name = unified_payload.event_name - logger.debug('Processing AI guardrails hook', extra={'event_name': event_name, 'tool': tool}) + logger.debug('Processing AI guardrails hook', extra={'event_name': event_name, 'ide': ide_integration.name}) workspace_roots = payload.get('workspace_roots', ['.']) policy = load_policy(workspace_roots[0]) @@ -117,26 +122,33 @@ def scan_command( 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()) + output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) return - response = handler(ctx, unified_payload, policy) - logger.debug('Hook handler completed', extra={'event_name': event_name, 'response': response}) - output_json(response) + decision = handler(ctx, unified_payload, policy) + logger.debug('Hook handler completed', extra={'event_name': event_name, 'action': decision.action.value}) + output_json(ide_integration.build_hook_response(decision)) 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')) + output_json( + ide_integration.build_hook_response( + _deny_for_event(event_name, _get_auth_error_message(e), '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')) + output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) return - output_json(response_builder.deny_permission('Cycode guardrails error', 'Blocking due to fail-closed policy')) + output_json( + ide_integration.build_hook_response( + _deny_for_event( + event_name, + 'Cycode guardrails error - blocking due to fail-closed policy' + if event_name == AiHookEventType.PROMPT + else '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 index 585c7820..da42ed23 100644 --- a/cycode/cli/apps/ai_guardrails/scan/types.py +++ b/cycode/cli/apps/ai_guardrails/scan/types.py @@ -1,4 +1,9 @@ -"""Type definitions for AI guardrails.""" +"""Canonical event types and outcome enums for AI guardrails. + +Per-IDE event-name mappings live on the IDE class (in +`cycode/cli/apps/ai_guardrails/ides/`); only the IDE-agnostic enums are kept +here. +""" import sys @@ -13,36 +18,13 @@ def __str__(self) -> str: 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. - """ + """Canonical, IDE-agnostic hook event types.""" 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, -} - -# 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.""" @@ -52,11 +34,7 @@ class AIHookOutcome(StrEnum): 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. - """ + """Categorical reason for blocking (sent to backend for tracking).""" SECRETS_IN_PROMPT = 'secrets_in_prompt' SECRETS_IN_FILE = 'secrets_in_file' diff --git a/cycode/cli/apps/ai_guardrails/session_start_command.py b/cycode/cli/apps/ai_guardrails/session_start_command.py index f2d5031c..cda53c62 100644 --- a/cycode/cli/apps/ai_guardrails/session_start_command.py +++ b/cycode/cli/apps/ai_guardrails/session_start_command.py @@ -1,18 +1,12 @@ +"""Handle AI guardrails session start: auth, conversation creation, session context.""" + import sys from typing import TYPE_CHECKING, Annotated, Optional import typer -from cycode.cli.apps.ai_guardrails.consts import AIIDEType -from cycode.cli.apps.ai_guardrails.scan.claude_config import ( - get_mcp_servers, - get_user_email, - load_claude_config, - load_claude_settings, - resolve_plugins, -) -from cycode.cli.apps.ai_guardrails.scan.cursor_config import load_cursor_config -from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload, extract_from_claude_transcript +from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, get_ide +from cycode.cli.apps.ai_guardrails.ides.base import IDE from cycode.cli.apps.ai_guardrails.scan.utils import safe_json_parse from cycode.cli.apps.auth.auth_common import get_authorization_info from cycode.cli.apps.auth.auth_manager import AuthManager @@ -26,69 +20,10 @@ logger = get_logger('AI Guardrails') -def _build_session_payload(payload: dict, ide: str) -> AIHookPayload: - """Build an AIHookPayload from a session-start stdin payload.""" - if ide == AIIDEType.CLAUDE_CODE: - claude_config = load_claude_config() - ide_user_email = get_user_email(claude_config) if claude_config else None - ide_version, _, _ = extract_from_claude_transcript(payload.get('transcript_path')) - - return AIHookPayload( - conversation_id=payload.get('session_id'), - ide_user_email=ide_user_email, - model=payload.get('model'), - ide_provider=AIIDEType.CLAUDE_CODE.value, - ide_version=ide_version, - source=payload.get('source'), - ) - - # Cursor - return AIHookPayload( - conversation_id=payload.get('conversation_id'), - ide_user_email=payload.get('user_email'), - model=payload.get('model'), - ide_provider=AIIDEType.CURSOR.value, - ide_version=payload.get('cursor_version'), - ) - - -def _get_claude_code_session_context() -> tuple[dict, dict]: - """Return (mcp_servers, enabled_plugins) for Claude Code. - - Merges MCP servers from ~/.claude.json (user-configured) with those contributed - by enabled plugins. Plugin metadata (name, version, description) is included in - the enabled_plugins dict when resolvable. - """ - config = load_claude_config() - mcp_servers = dict(get_mcp_servers(config) or {}) if config else {} - - settings = load_claude_settings() - if settings: - plugin_mcp, enriched_plugins = resolve_plugins(settings) - mcp_servers.update(plugin_mcp) - else: - enriched_plugins = {} - - return mcp_servers, enriched_plugins - - -def _get_cursor_session_context() -> tuple[dict, dict]: - """Return (mcp_servers, enabled_plugins) for Cursor. Cursor has no plugin system.""" - config = load_cursor_config() - mcp_servers = dict(get_mcp_servers(config) or {}) if config else {} - return mcp_servers, {} - - -def _report_session_context(ai_client: 'AISecurityManagerClient', ide: str, user_email: Optional[str]) -> None: +def _report_session_context(ai_client: 'AISecurityManagerClient', ide: IDE, user_email: Optional[str]) -> None: """Report IDE session context to the AI security manager. Never raises.""" try: - if ide == AIIDEType.CLAUDE_CODE: - mcp_servers, enabled_plugins = _get_claude_code_session_context() - elif ide == AIIDEType.CURSOR: - mcp_servers, enabled_plugins = _get_cursor_session_context() - else: - return - + mcp_servers, enabled_plugins = ide.get_session_context() if not mcp_servers and not enabled_plugins: return ai_client.report_session_context( @@ -109,16 +44,17 @@ def session_start_command( help='IDE that triggered the session start.', hidden=True, ), - ] = AIIDEType.CURSOR.value, + ] = DEFAULT_IDE_NAME, ) -> None: """Handle session start: ensure auth, create conversation, report session context.""" + ide_integration = get_ide(ide) + # Step 1: Ensure authentication auth_info = get_authorization_info(ctx) if auth_info is None: logger.debug('Not authenticated, starting authentication') try: - auth_manager = AuthManager() - auth_manager.authenticate() + AuthManager().authenticate() except Exception as err: handle_auth_exception(ctx, err) return @@ -136,8 +72,8 @@ def session_start_command( logger.debug('Empty or invalid stdin payload, skipping session initialization') return - # Step 3: Build session payload and initialize API client - session_payload = _build_session_payload(payload, ide) + # Step 3: Build session payload + initialize API client + session_payload = ide_integration.build_session_payload(payload) try: ai_client = get_ai_security_manager_client(ctx) @@ -151,5 +87,5 @@ def session_start_command( except Exception as e: logger.debug('Failed to create conversation during session start', exc_info=e) - # Step 5: Report session context (MCP servers) - _report_session_context(ai_client, ide, session_payload.ide_user_email) + # Step 5: Report session context (MCP servers, enabled plugins) + _report_session_context(ai_client, ide_integration, session_payload.ide_user_email) diff --git a/cycode/cli/apps/ai_guardrails/status_command.py b/cycode/cli/apps/ai_guardrails/status_command.py index ee1e5bcf..da201545 100644 --- a/cycode/cli/apps/ai_guardrails/status_command.py +++ b/cycode/cli/apps/ai_guardrails/status_command.py @@ -7,9 +7,9 @@ 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.consts import IDE_CONFIGS, AIIDEType +from cycode.cli.apps.ai_guardrails.command_utils import console, validate_scope from cycode.cli.apps.ai_guardrails.hooks_manager import get_hooks_status +from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, IDES, resolve_ides def status_command( @@ -26,9 +26,9 @@ def status_command( str, typer.Option( '--ide', - help='IDE to check status for (e.g., "cursor", "claude-code", or "all" for all IDEs). Defaults to cursor.', + help=f'IDE to check status for ({", ".join(IDES)}, or "all").', ), - ] = AIIDEType.CURSOR.value, + ] = DEFAULT_IDE_NAME, repo_path: Annotated[ Optional[Path], typer.Option( @@ -43,32 +43,30 @@ def status_command( ) -> 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 - cycode ai-guardrails status --ide all # Check status for all supported IDEs + cycode ai-guardrails status --ide claude-code + cycode ai-guardrails status --ide all # Check every supported IDE """ - # 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) - - ides_to_check: list[AIIDEType] = list(AIIDEType) if ide_type is None else [ide_type] + ides_to_check = resolve_ides(ide) 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 cyan]═══ {ide_name} ═══[/]') + console.print(f'[bold cyan]═══ {current_ide.display_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) + status = get_hooks_status( + current_ide, + check_scope, + repo_path if check_scope == 'repo' else None, + ) console.print() console.print(f'[bold]{check_scope.upper()} SCOPE[/]') @@ -83,7 +81,6 @@ def status_command( 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') diff --git a/cycode/cli/apps/ai_guardrails/uninstall_command.py b/cycode/cli/apps/ai_guardrails/uninstall_command.py index f7b8341c..f9a995f3 100644 --- a/cycode/cli/apps/ai_guardrails/uninstall_command.py +++ b/cycode/cli/apps/ai_guardrails/uninstall_command.py @@ -5,14 +5,9 @@ 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, AIIDEType +from cycode.cli.apps.ai_guardrails.command_utils import console, resolve_repo_path, validate_scope from cycode.cli.apps.ai_guardrails.hooks_manager import uninstall_hooks +from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, IDES, resolve_ides def uninstall_command( @@ -29,9 +24,9 @@ def uninstall_command( str, typer.Option( '--ide', - help='IDE to uninstall hooks from (e.g., "cursor", "claude-code", "all"). Defaults to cursor.', + help=f'IDE to uninstall hooks from ({", ".join(IDES)}, or "all").', ), - ] = AIIDEType.CURSOR.value, + ] = DEFAULT_IDE_NAME, repo_path: Annotated[ Optional[Path], typer.Option( @@ -46,32 +41,27 @@ def uninstall_command( ) -> 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. + Removes Cycode hooks from the IDE's hooks configuration. Other hooks + (if any) are 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 - cycode ai-guardrails uninstall --ide all # Uninstall from all supported IDEs + cycode ai-guardrails uninstall --ide claude-code # Uninstall from a specific IDE + cycode ai-guardrails uninstall --ide all # Uninstall from every supported IDE """ - # Validate inputs validate_scope(scope) repo_path = resolve_repo_path(scope, repo_path) - ide_type = validate_and_parse_ide(ide) - - ides_to_uninstall: list[AIIDEType] = list(AIIDEType) if ide_type is None else [ide_type] + ides_to_uninstall = resolve_ides(ide) 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)) + success, message = uninstall_hooks(current_ide, scope, repo_path) + results.append((current_ide.display_name, success, message)) - # Report results for each IDE any_success = False all_success = True - for _ide_name, success, message in results: + for _name, success, message in results: if success: console.print(f'[green]✓[/] {message}') any_success = True diff --git a/tests/cli/commands/ai_guardrails/ides/__init__.py b/tests/cli/commands/ai_guardrails/ides/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py new file mode 100644 index 00000000..f997abe3 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py @@ -0,0 +1,262 @@ +"""Claude Code IDE integration tests.""" + +import json +from pathlib import Path +from unittest.mock import patch + +from pyfakefs.fake_filesystem import FakeFilesystem +from pytest_mock import MockerFixture + +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision +from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode, _email_from_config, load_claude_config +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + + +def test_matches_payload_only_claude_events() -> None: + claude = ClaudeCode() + assert claude.matches_payload({'hook_event_name': 'UserPromptSubmit'}) is True + assert claude.matches_payload({'hook_event_name': 'PreToolUse'}) is True + assert claude.matches_payload({'hook_event_name': 'beforeSubmitPrompt'}) is False + assert claude.matches_payload({'hook_event_name': 'beforeReadFile'}) is False + + +def test_parse_prompt_payload() -> None: + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'Test prompt', + } + ) + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id == 'session-123' + assert unified.ide_provider == 'claude-code' + assert unified.prompt == 'Test prompt' + + +def test_parse_file_read_payload() -> None: + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'PreToolUse', + 'session_id': 'session-456', + 'tool_name': 'Read', + 'tool_input': {'file_path': '/path/to/secret.env'}, + } + ) + assert unified.event_name == AiHookEventType.FILE_READ + assert unified.file_path == '/path/to/secret.env' + assert unified.mcp_tool_name is None + + +def test_parse_mcp_execution_payload() -> None: + args = {'resource_type': 'merge_request', 'parent_id': 'org/repo', 'resource_id': '4'} + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'PreToolUse', + 'tool_name': 'mcp__gitlab__discussion_list', + 'tool_input': args, + } + ) + + assert unified.event_name == AiHookEventType.MCP_EXECUTION + assert unified.mcp_server_name == 'gitlab' + assert unified.mcp_tool_name == 'discussion_list' + assert unified.mcp_arguments == args + + +def test_parse_empty_payload_defaults() -> None: + unified = ClaudeCode().parse_hook_payload({'hook_event_name': 'UserPromptSubmit'}) + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id is None + assert unified.prompt == '' + assert unified.ide_provider == 'claude-code' + + +def test_build_prompt_responses() -> None: + claude = ClaudeCode() + assert claude.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT)) == {} + assert claude.build_hook_response(HookDecision.deny(AiHookEventType.PROMPT, 'no!')) == { + 'decision': 'block', + 'reason': 'no!', + } + + +def test_build_permission_responses() -> None: + claude = ClaudeCode() + allow = claude.build_hook_response(HookDecision.allow(AiHookEventType.FILE_READ)) + assert allow == {'hookSpecificOutput': {'hookEventName': 'PreToolUse', 'permissionDecision': 'allow'}} + + deny = claude.build_hook_response(HookDecision.deny(AiHookEventType.FILE_READ, 'user!', 'agent!')) + assert deny == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': 'user!', + } + } + + ask = claude.build_hook_response(HookDecision.ask(AiHookEventType.MCP_EXECUTION, 'u')) + assert ask == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'ask', + 'permissionDecisionReason': 'u', + } + } + + +# Transcript extraction + + +def test_extract_from_transcript(mocker: MockerFixture) -> None: + """version, model, generation_id from a Claude Code transcript JSONL.""" + 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.ides.claude_code.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 + + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test prompt', + 'transcript_path': '/mock/transcript.jsonl', + } + ) + + assert unified.ide_version == '2.1.20' + assert unified.model == 'claude-opus-4-5-20251101' + assert unified.generation_id == 'user-uuid-2' + + +def test_missing_transcript_does_not_break_parsing(mocker: MockerFixture) -> None: + mock_path = mocker.patch('cycode.cli.apps.ai_guardrails.ides.claude_code.Path') + mock_path.return_value.exists.return_value = False + + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + 'transcript_path': '/nonexistent/path/transcript.jsonl', + } + ) + + 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_absent_transcript_path() -> None: + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + } + ) + assert unified.ide_version is None + assert unified.model is None + assert unified.generation_id is None + + +# Email extraction from ~/.claude.json + + +def test_email_from_config(mocker: MockerFixture) -> None: + mocker.patch( + 'cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_config', + return_value={'oauthAccount': {'emailAddress': 'user@example.com'}}, + ) + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + } + ) + assert unified.ide_user_email == 'user@example.com' + + +def test_email_none_when_config_missing(mocker: MockerFixture) -> None: + mocker.patch( + 'cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_config', + return_value=None, + ) + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + } + ) + assert unified.ide_user_email is None + + +def test_email_none_when_no_oauth(mocker: MockerFixture) -> None: + mocker.patch( + 'cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_config', + return_value={'someOtherKey': 'value'}, + ) + unified = ClaudeCode().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + } + ) + assert unified.ide_user_email is None + + +# Session context + + +def test_session_context_no_config() -> None: + with ( + patch('cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_config', return_value=None), + patch('cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_settings', return_value=None), + ): + servers, plugins = ClaudeCode().get_session_context() + assert servers == {} + assert plugins == {} + + +# Claude config parsing (load_claude_config + _email_from_config) + + +def test_load_claude_config_valid(fs: FakeFilesystem) -> None: + config = {'oauthAccount': {'emailAddress': 'user@example.com'}} + config_path = Path.home() / '.claude.json' + fs.create_file(config_path, contents=json.dumps(config)) + + assert load_claude_config(config_path) == config + + +def test_load_claude_config_missing_file(fs: FakeFilesystem) -> None: + fs.create_dir(Path.home()) + assert load_claude_config(Path.home() / '.claude.json') is None + + +def test_load_claude_config_corrupt_file(fs: FakeFilesystem) -> None: + config_path = Path.home() / '.claude.json' + fs.create_file(config_path, contents='not valid json {{{') + + assert load_claude_config(config_path) is None + + +def test_email_from_config_present() -> None: + assert _email_from_config({'oauthAccount': {'emailAddress': 'user@example.com'}}) == 'user@example.com' + + +def test_email_from_config_missing_oauth_account() -> None: + assert _email_from_config({'someOtherKey': 'value'}) is None + + +def test_email_from_config_missing_email_address() -> None: + assert _email_from_config({'oauthAccount': {'someOtherField': 'value'}}) is None diff --git a/tests/cli/commands/ai_guardrails/ides/test_contract.py b/tests/cli/commands/ai_guardrails/ides/test_contract.py new file mode 100644 index 00000000..9714dbfa --- /dev/null +++ b/tests/cli/commands/ai_guardrails/ides/test_contract.py @@ -0,0 +1,141 @@ +"""IDE contract tests, parameterized over the entire IDES registry. + +Every concrete IDE registered in `ides/__init__.py` must satisfy these +assertions. Adding a new IDE without updating these tests means the new +IDE inherits the same baseline guarantees (and fails fast if it doesn't). +""" + +from pathlib import Path + +import pytest + +from cycode.cli.apps.ai_guardrails.ides import IDES +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + + +def test_ides_registry_is_non_empty() -> None: + """Sanity check: the refactor isn't useful with zero registered IDEs.""" + assert len(IDES) >= 1 + + +@pytest.fixture(params=sorted(IDES), ids=sorted(IDES)) +def ide(request: pytest.FixtureRequest) -> IDE: + return IDES[request.param] + + +def test_identity_attributes_set(ide: IDE) -> None: + """Every IDE must declare name, display_name, hook_events.""" + assert isinstance(ide.name, str) + assert ide.name + assert isinstance(ide.display_name, str) + assert ide.display_name + assert isinstance(ide.hook_events, list) + assert ide.hook_events + + +def test_registry_key_matches_name(ide: IDE) -> None: + """The registry key must equal the IDE's own `name` attribute.""" + assert IDES[ide.name] is ide + + +def test_settings_path_user_scope(ide: IDE) -> None: + """User scope must return a Path (without requiring a repo_path).""" + path = ide.settings_path('user') + assert isinstance(path, Path) + + +def test_settings_path_repo_scope(ide: IDE, tmp_path: Path) -> None: + """Repo scope path must live under the supplied repo directory.""" + path = ide.settings_path('repo', tmp_path) + assert isinstance(path, Path) + assert str(path).startswith(str(tmp_path)) + + +def test_render_hooks_config_has_hooks_key(ide: IDE) -> None: + """All IDEs share the outer `{"hooks": ...}` wrapper so hooks_manager can merge.""" + rendered = ide.render_hooks_config() + assert isinstance(rendered, dict) + assert 'hooks' in rendered + assert isinstance(rendered['hooks'], dict) + + +def test_render_hooks_config_async_changes_output(ide: IDE) -> None: + """async_mode must influence the rendered output (e.g. & suffix, async flag).""" + assert ide.render_hooks_config(async_mode=False) != ide.render_hooks_config(async_mode=True) + + +def test_matches_payload_rejects_empty(ide: IDE) -> None: + """Empty payloads can't legitimately come from any IDE.""" + assert ide.matches_payload({}) is False + assert ide.matches_payload({'hook_event_name': ''}) is False + + +def test_matches_payload_rejects_unrelated_event_names(ide: IDE) -> None: + """Unknown event names from other IDEs must be ignored to avoid double-processing.""" + assert ide.matches_payload({'hook_event_name': 'completely-fabricated-event'}) is False + + +@pytest.mark.parametrize('event_type', list(AiHookEventType)) +def test_build_hook_response_allow_returns_dict(ide: IDE, event_type: AiHookEventType) -> None: + """ALLOW for every canonical event type yields a serializable dict.""" + response = ide.build_hook_response(HookDecision.allow(event_type)) + assert isinstance(response, dict) + + +@pytest.mark.parametrize('event_type', list(AiHookEventType)) +def test_build_hook_response_deny_carries_message(ide: IDE, event_type: AiHookEventType) -> None: + """DENY must surface the user message somewhere in the response (any key).""" + response = ide.build_hook_response(HookDecision.deny(event_type, 'A unique deny reason', 'agent msg')) + # Search recursively — IDEs use different key names for the message. + assert _contains_value(response, 'A unique deny reason'), response + + +@pytest.mark.parametrize('event_type', [AiHookEventType.FILE_READ, AiHookEventType.MCP_EXECUTION]) +def test_build_hook_response_ask_carries_message(ide: IDE, event_type: AiHookEventType) -> None: + """ASK is meaningful for permission events. Message must propagate.""" + response = ide.build_hook_response(HookDecision.ask(event_type, 'A unique ask reason')) + assert _contains_value(response, 'A unique ask reason'), response + + +def test_build_session_payload_tags_ide(ide: IDE) -> None: + """Session payload must identify the originating IDE.""" + session = ide.build_session_payload({}) + assert session.ide_provider == ide.name + + +def test_get_session_context_returns_pair(ide: IDE) -> None: + """Session context must always be a ``(mcp_servers, plugins)`` 2-tuple of dicts.""" + mcp_servers, plugins = ide.get_session_context() + assert isinstance(mcp_servers, dict) + assert isinstance(plugins, dict) + + +# HookDecision helpers + + +def test_hook_decision_helpers() -> None: + allow = HookDecision.allow(AiHookEventType.PROMPT) + assert allow.action == DecisionAction.ALLOW + assert allow.event_type == AiHookEventType.PROMPT + assert allow.user_message is None + + deny = HookDecision.deny(AiHookEventType.FILE_READ, 'why', 'agent') + assert deny.action == DecisionAction.DENY + assert deny.user_message == 'why' + assert deny.agent_message == 'agent' + + ask = HookDecision.ask(AiHookEventType.MCP_EXECUTION, 'maybe?') + assert ask.action == DecisionAction.ASK + assert ask.user_message == 'maybe?' + + +def _contains_value(obj: object, needle: str) -> bool: + """Recursively search a nested dict/list for a string value.""" + if isinstance(obj, str): + return needle in obj + if isinstance(obj, dict): + return any(_contains_value(v, needle) for v in obj.values()) + if isinstance(obj, list): + return any(_contains_value(v, needle) for v in obj) + return False diff --git a/tests/cli/commands/ai_guardrails/ides/test_cursor.py b/tests/cli/commands/ai_guardrails/ides/test_cursor.py new file mode 100644 index 00000000..bb058f6f --- /dev/null +++ b/tests/cli/commands/ai_guardrails/ides/test_cursor.py @@ -0,0 +1,154 @@ +"""Cursor IDE integration tests (payload parsing, response building, MCP context).""" + +import json +from pathlib import Path +from typing import Any +from unittest.mock import patch + +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision +from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + + +def test_matches_payload_only_cursor_events() -> None: + cursor = Cursor() + assert cursor.matches_payload({'hook_event_name': 'beforeSubmitPrompt'}) is True + assert cursor.matches_payload({'hook_event_name': 'beforeReadFile'}) is True + assert cursor.matches_payload({'hook_event_name': 'beforeMCPExecution'}) is True + assert cursor.matches_payload({'hook_event_name': 'UserPromptSubmit'}) is False + assert cursor.matches_payload({'hook_event_name': 'PreToolUse'}) is False + + +def test_parse_prompt_payload() -> None: + 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 = Cursor().parse_hook_payload(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_parse_file_read_payload() -> None: + unified = Cursor().parse_hook_payload({'hook_event_name': 'beforeReadFile', 'file_path': '/path/to/secret.env'}) + assert unified.event_name == AiHookEventType.FILE_READ + assert unified.file_path == '/path/to/secret.env' + + +def test_parse_mcp_execution_payload() -> None: + args: dict[str, Any] = {'resource_type': 'merge_request', 'parent_id': 'org/repo', 'resource_id': '4'} + unified = Cursor().parse_hook_payload( + { + 'hook_event_name': 'beforeMCPExecution', + 'command': 'GitLab', + 'tool_name': 'discussion_list', + 'arguments': args, + } + ) + + assert unified.event_name == AiHookEventType.MCP_EXECUTION + assert unified.mcp_server_name == 'GitLab' + assert unified.mcp_tool_name == 'discussion_list' + assert unified.mcp_arguments == args + + +def test_parse_alternative_field_names() -> None: + """Cursor's payload has alternative names for some fields.""" + fr = Cursor().parse_hook_payload({'hook_event_name': 'beforeReadFile', 'path': '/alt/path.txt'}) + assert fr.file_path == '/alt/path.txt' + + mcp = Cursor().parse_hook_payload( + { + 'hook_event_name': 'beforeMCPExecution', + 'tool': 'my_tool', + 'tool_input': {'key': 'value'}, + } + ) + assert mcp.mcp_tool_name == 'my_tool' + assert mcp.mcp_arguments == {'key': 'value'} + + +def test_parse_unknown_event_name_falls_through() -> None: + """Unknown event names pass through as the raw string.""" + unified = Cursor().parse_hook_payload({'hook_event_name': 'unknownEvent'}) + assert unified.event_name == 'unknownEvent' + + +def test_parse_empty_payload_defaults() -> None: + unified = Cursor().parse_hook_payload({'hook_event_name': 'beforeSubmitPrompt'}) + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id is None + assert unified.prompt == '' + assert unified.ide_provider == 'cursor' + + +def test_build_prompt_responses() -> None: + cursor = Cursor() + assert cursor.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT)) == {'continue': True} + assert cursor.build_hook_response(HookDecision.deny(AiHookEventType.PROMPT, 'no!')) == { + 'continue': False, + 'user_message': 'no!', + } + + +def test_build_permission_responses() -> None: + cursor = Cursor() + assert cursor.build_hook_response(HookDecision.allow(AiHookEventType.FILE_READ)) == {'permission': 'allow'} + assert cursor.build_hook_response(HookDecision.deny(AiHookEventType.FILE_READ, 'user!', 'agent!')) == { + 'permission': 'deny', + 'user_message': 'user!', + 'agent_message': 'agent!', + } + assert cursor.build_hook_response(HookDecision.ask(AiHookEventType.MCP_EXECUTION, 'u', 'a')) == { + 'permission': 'ask', + 'user_message': 'u', + 'agent_message': 'a', + } + + +def test_session_payload_carries_cursor_fields() -> None: + payload = { + 'conversation_id': 'conv-456', + 'user_email': 'cursor-user@example.com', + 'model': 'gpt-4', + 'cursor_version': '0.42.0', + } + session = Cursor().build_session_payload(payload) + assert session.conversation_id == 'conv-456' + assert session.model == 'gpt-4' + assert session.ide_user_email == 'cursor-user@example.com' + assert session.ide_version == '0.42.0' + assert session.ide_provider == 'cursor' + + +def test_session_context_loads_mcp_servers(tmp_path: Path) -> None: + """Cursor reads MCP servers from ~/.cursor/mcp.json.""" + mcp_servers = {'github': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-github']}} + config_path = tmp_path / 'mcp.json' + config_path.write_text(json.dumps({'mcpServers': mcp_servers})) + + with patch('cycode.cli.apps.ai_guardrails.ides.cursor._load_cursor_mcp_config') as load: + load.return_value = {'mcpServers': mcp_servers} + servers, plugins = Cursor().get_session_context() + + assert servers == mcp_servers + assert plugins == {} + + +def test_session_context_no_config_returns_empty() -> None: + with patch('cycode.cli.apps.ai_guardrails.ides.cursor._load_cursor_mcp_config', return_value=None): + servers, plugins = Cursor().get_session_context() + assert servers == {} + assert plugins == {} diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index 57c25b92..36352a38 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -6,13 +6,14 @@ import pytest import typer +from cycode.cli.apps.ai_guardrails.ides.base import DecisionAction, HookDecision 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 +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType, AIHookOutcome, BlockReason @pytest.fixture @@ -30,7 +31,7 @@ def mock_ctx() -> MagicMock: def mock_payload() -> AIHookPayload: """Create a mock AIHookPayload.""" return AIHookPayload( - event_name='prompt', + event_name='Prompt', conversation_id='test-conv-id', generation_id='test-gen-id', ide_user_email='test@example.com', @@ -65,7 +66,7 @@ def test_handle_before_submit_prompt_disabled( result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) - assert result == {'continue': True} + assert result == HookDecision.allow(AiHookEventType.PROMPT) mock_ctx.obj['ai_security_client'].create_event.assert_called_once() mock_ctx.obj['ai_security_client'].create_conversation.assert_not_called() @@ -79,11 +80,10 @@ def test_handle_before_submit_prompt_no_secrets( result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) - assert result == {'continue': True} + assert result == HookDecision.allow(AiHookEventType.PROMPT) mock_ctx.obj['ai_security_client'].create_event.assert_called_once() mock_ctx.obj['ai_security_client'].create_conversation.assert_not_called() 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 @@ -98,8 +98,9 @@ def test_handle_before_submit_prompt_with_secrets_blocked( 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'] + assert result.action == DecisionAction.DENY + assert result.event_type == AiHookEventType.PROMPT + 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 @@ -116,7 +117,7 @@ def test_handle_before_submit_prompt_with_secrets_warned( result = handle_before_submit_prompt(mock_ctx, mock_payload, default_policy) - assert result == {'continue': True} + assert result == HookDecision.allow(AiHookEventType.PROMPT) 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 @@ -133,11 +134,9 @@ def test_handle_before_submit_prompt_scan_failure_fail_open( 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 - # block_reason is set for tracking even when fail_open allows the action assert call_args.kwargs['block_reason'] == BlockReason.SCAN_FAILURE @@ -152,7 +151,6 @@ def test_handle_before_submit_prompt_scan_failure_fail_closed( 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 @@ -166,14 +164,14 @@ def test_handle_before_read_file_disabled(mock_ctx: MagicMock, default_policy: d """Test that disabled file read scanning allows the file.""" default_policy['file_read']['enabled'] = False payload = AIHookPayload( - event_name='file_read', + event_name='FileRead', ide_provider='cursor', file_path='/path/to/file.txt', ) result = handle_before_read_file(mock_ctx, payload, default_policy) - assert result == {'permission': 'allow'} + assert result == HookDecision.allow(AiHookEventType.FILE_READ) @patch('cycode.cli.apps.ai_guardrails.scan.handlers.is_denied_path') @@ -183,15 +181,16 @@ def test_handle_before_read_file_sensitive_path( """Test that sensitive path is blocked.""" mock_is_denied.return_value = True payload = AIHookPayload( - event_name='file_read', + event_name='FileRead', 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'] + assert result.action == DecisionAction.DENY + assert result.event_type == AiHookEventType.FILE_READ + 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 @@ -208,14 +207,14 @@ def test_handle_before_read_file_no_secrets( mock_is_denied.return_value = False mock_scan.return_value = (None, 'scan-id-123') payload = AIHookPayload( - event_name='file_read', + event_name='FileRead', ide_provider='cursor', file_path='/path/to/file.txt', ) result = handle_before_read_file(mock_ctx, payload, default_policy) - assert result == {'permission': 'allow'} + assert result == HookDecision.allow(AiHookEventType.FILE_READ) 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' @@ -230,15 +229,16 @@ def test_handle_before_read_file_with_secrets( mock_is_denied.return_value = False mock_scan.return_value = ('Found 1 secret: password', 'scan-id-456') payload = AIHookPayload( - event_name='file_read', + event_name='FileRead', 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'] + assert result.action == DecisionAction.DENY + assert result.event_type == AiHookEventType.FILE_READ + 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 @@ -254,14 +254,14 @@ def test_handle_before_read_file_scan_disabled( mock_is_denied.return_value = False default_policy['file_read']['scan_content'] = False payload = AIHookPayload( - event_name='file_read', + event_name='FileRead', ide_provider='cursor', file_path='/path/to/file.txt', ) result = handle_before_read_file(mock_ctx, payload, default_policy) - assert result == {'permission': 'allow'} + assert result == HookDecision.allow(AiHookEventType.FILE_READ) mock_scan.assert_not_called() @@ -275,20 +275,18 @@ def test_handle_before_read_file_sensitive_path_warn_mode_scans_content( mock_scan.return_value = (None, 'scan-id-123') default_policy['mode'] = 'warn' payload = AIHookPayload( - event_name='file_read', + event_name='FileRead', ide_provider='cursor', file_path='/path/to/.env', ) result = handle_before_read_file(mock_ctx, payload, default_policy) - # Content was scanned even though path is sensitive mock_scan.assert_called_once() - # Still warns about sensitive path since no secrets found - assert result['permission'] == 'ask' - assert '.env' in result['user_message'] + assert result.action == DecisionAction.ASK + assert result.event_type == AiHookEventType.FILE_READ + assert '.env' in result.user_message - # Two events: sensitive path warn + content scan result (allowed, no secrets found) assert mock_ctx.obj['ai_security_client'].create_event.call_count == 2 first_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[0] assert first_event.args[2] == AIHookOutcome.WARNED @@ -308,7 +306,7 @@ def test_handle_before_read_file_sensitive_path_warn_mode_with_secrets( mock_scan.return_value = ('Found 1 secret: API key', 'scan-id-456') default_policy['mode'] = 'warn' payload = AIHookPayload( - event_name='file_read', + event_name='FileRead', ide_provider='cursor', file_path='/path/to/.env', ) @@ -316,10 +314,10 @@ def test_handle_before_read_file_sensitive_path_warn_mode_with_secrets( result = handle_before_read_file(mock_ctx, payload, default_policy) mock_scan.assert_called_once() - assert result['permission'] == 'ask' - assert 'Found 1 secret: API key' in result['user_message'] + assert result.action == DecisionAction.ASK + assert result.event_type == AiHookEventType.FILE_READ + assert 'Found 1 secret: API key' in result.user_message - # Two events: sensitive path warn + secrets warn assert mock_ctx.obj['ai_security_client'].create_event.call_count == 2 first_event = mock_ctx.obj['ai_security_client'].create_event.call_args_list[0] assert first_event.args[2] == AIHookOutcome.WARNED @@ -339,7 +337,7 @@ def test_handle_before_read_file_sensitive_path_scan_disabled_warns( default_policy['mode'] = 'warn' default_policy['file_read']['scan_content'] = False payload = AIHookPayload( - event_name='file_read', + event_name='FileRead', ide_provider='cursor', file_path='/path/to/.env', ) @@ -347,10 +345,10 @@ def test_handle_before_read_file_sensitive_path_scan_disabled_warns( result = handle_before_read_file(mock_ctx, payload, default_policy) mock_scan.assert_not_called() - assert result['permission'] == 'ask' - assert '.env' in result['user_message'] + assert result.action == DecisionAction.ASK + assert result.event_type == AiHookEventType.FILE_READ + assert '.env' in result.user_message - # Single event: sensitive path warn (no separate scan event when scan is disabled) 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 @@ -375,7 +373,7 @@ def test_handle_before_mcp_execution_disabled(mock_ctx: MagicMock, default_polic """Test that disabled MCP scanning allows the execution.""" default_policy['mcp']['enabled'] = False payload = AIHookPayload( - event_name='mcp_execution', + event_name='McpExecution', ide_provider='cursor', mcp_tool_name='test_tool', mcp_arguments={'arg1': 'value1'}, @@ -383,7 +381,7 @@ def test_handle_before_mcp_execution_disabled(mock_ctx: MagicMock, default_polic result = handle_before_mcp_execution(mock_ctx, payload, default_policy) - assert result == {'permission': 'allow'} + assert result == HookDecision.allow(AiHookEventType.MCP_EXECUTION) @patch('cycode.cli.apps.ai_guardrails.scan.handlers._scan_text_for_secrets') @@ -393,7 +391,7 @@ def test_handle_before_mcp_execution_no_secrets( """Test that MCP execution with no secrets is allowed.""" mock_scan.return_value = (None, 'scan-id-123') payload = AIHookPayload( - event_name='mcp_execution', + event_name='McpExecution', ide_provider='cursor', mcp_tool_name='test_tool', mcp_arguments={'arg1': 'value1'}, @@ -401,7 +399,7 @@ def test_handle_before_mcp_execution_no_secrets( result = handle_before_mcp_execution(mock_ctx, payload, default_policy) - assert result == {'permission': 'allow'} + assert result == HookDecision.allow(AiHookEventType.MCP_EXECUTION) call_args = mock_ctx.obj['ai_security_client'].create_event.call_args assert call_args.args[2] == AIHookOutcome.ALLOWED @@ -413,7 +411,7 @@ def test_handle_before_mcp_execution_with_secrets_blocked( """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', + event_name='McpExecution', ide_provider='cursor', mcp_tool_name='test_tool', mcp_arguments={'arg1': 'secret_token_12345'}, @@ -421,8 +419,9 @@ def test_handle_before_mcp_execution_with_secrets_blocked( result = handle_before_mcp_execution(mock_ctx, payload, default_policy) - assert result['permission'] == 'deny' - assert 'Found 1 secret: token' in result['user_message'] + assert result.action == DecisionAction.DENY + assert result.event_type == AiHookEventType.MCP_EXECUTION + 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 @@ -436,7 +435,7 @@ def test_handle_before_mcp_execution_with_secrets_warned( mock_scan.return_value = ('Found 1 secret: token', 'scan-id-789') default_policy['mcp']['action'] = 'warn' payload = AIHookPayload( - event_name='mcp_execution', + event_name='McpExecution', ide_provider='cursor', mcp_tool_name='test_tool', mcp_arguments={'arg1': 'secret_token_12345'}, @@ -444,8 +443,9 @@ def test_handle_before_mcp_execution_with_secrets_warned( result = handle_before_mcp_execution(mock_ctx, payload, default_policy) - assert result['permission'] == 'ask' - assert 'Found 1 secret: token' in result['user_message'] + assert result.action == DecisionAction.ASK + assert result.event_type == AiHookEventType.MCP_EXECUTION + 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 @@ -457,7 +457,7 @@ def test_handle_before_mcp_execution_scan_disabled( """Test that MCP execution is allowed when argument scanning is disabled.""" default_policy['mcp']['scan_arguments'] = False payload = AIHookPayload( - event_name='mcp_execution', + event_name='McpExecution', ide_provider='cursor', mcp_tool_name='test_tool', mcp_arguments={'arg1': 'value1'}, @@ -465,5 +465,5 @@ def test_handle_before_mcp_execution_scan_disabled( result = handle_before_mcp_execution(mock_ctx, payload, default_policy) - assert result == {'permission': 'allow'} + assert result == HookDecision.allow(AiHookEventType.MCP_EXECUTION) 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 deleted file mode 100644 index 1ef5fad0..00000000 --- a/tests/cli/commands/ai_guardrails/scan/test_payload.py +++ /dev/null @@ -1,432 +0,0 @@ -"""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 - - -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' - assert type(unified.ide_provider) is str - - -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' - - -# 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' - assert type(unified.ide_provider) is str - - -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' - - -# Claude Code email extraction tests - - -def test_from_claude_code_payload_extracts_email_from_config(mocker: MockerFixture) -> None: - """Test that ide_user_email is populated from ~/.claude.json.""" - mocker.patch( - 'cycode.cli.apps.ai_guardrails.scan.payload.load_claude_config', - return_value={'oauthAccount': {'emailAddress': 'user@example.com'}}, - ) - - claude_payload = { - 'hook_event_name': 'UserPromptSubmit', - 'session_id': 'session-123', - 'prompt': 'test', - } - - unified = AIHookPayload.from_claude_code_payload(claude_payload) - assert unified.ide_user_email == 'user@example.com' - - -def test_from_claude_code_payload_email_none_when_config_missing(mocker: MockerFixture) -> None: - """Test that ide_user_email is None when ~/.claude.json is missing.""" - mocker.patch( - 'cycode.cli.apps.ai_guardrails.scan.payload.load_claude_config', - return_value=None, - ) - - claude_payload = { - 'hook_event_name': 'UserPromptSubmit', - 'session_id': 'session-123', - 'prompt': 'test', - } - - unified = AIHookPayload.from_claude_code_payload(claude_payload) - assert unified.ide_user_email is None - - -def test_from_claude_code_payload_email_none_when_no_oauth(mocker: MockerFixture) -> None: - """Test that ide_user_email is None when oauthAccount is missing from config.""" - mocker.patch( - 'cycode.cli.apps.ai_guardrails.scan.payload.load_claude_config', - return_value={'someOtherKey': 'value'}, - ) - - claude_payload = { - 'hook_event_name': 'UserPromptSubmit', - 'session_id': 'session-123', - 'prompt': 'test', - } - - unified = AIHookPayload.from_claude_code_payload(claude_payload) - assert unified.ide_user_email is None - - -# 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 deleted file mode 100644 index 45f80829..00000000 --- a/tests/cli/commands/ai_guardrails/scan/test_response_builders.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Tests for IDE response builders.""" - -import pytest - -from cycode.cli.apps.ai_guardrails.scan.response_builders import ( - ClaudeCodeResponseBuilder, - 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 - - -# 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 index 4bcb35f2..35f7e4fa 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py +++ b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py @@ -9,7 +9,9 @@ from typer.testing import CliRunner from cycode.cli.apps.ai_guardrails import app as ai_guardrails_app +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision from cycode.cli.apps.ai_guardrails.scan.scan_command import scan_command +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @pytest.fixture @@ -129,7 +131,7 @@ def test_claude_code_payload_with_claude_code_ide( 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_handler = MagicMock(return_value=HookDecision.allow(AiHookEventType.PROMPT)) mock_scan_command_deps['get_handler'].return_value = mock_handler scan_command(mock_ctx, ide='claude-code') @@ -146,15 +148,15 @@ class TestDefaultIdeParameterViaCli: 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'). + Catches regressions where the default value would no longer match a + registered IDE name (e.g. after renaming `DEFAULT_IDE_NAME`). """ 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}) + mock_handler = MagicMock(return_value=HookDecision.allow(AiHookEventType.PROMPT)) mocker.patch( 'cycode.cli.apps.ai_guardrails.scan.scan_command.get_handler_for_event', return_value=mock_handler, diff --git a/tests/cli/commands/ai_guardrails/test_claude_config.py b/tests/cli/commands/ai_guardrails/test_claude_config.py deleted file mode 100644 index 6bbdbcab..00000000 --- a/tests/cli/commands/ai_guardrails/test_claude_config.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Tests for Claude Code config file reader.""" - -import json -from pathlib import Path - -from pyfakefs.fake_filesystem import FakeFilesystem - -from cycode.cli.apps.ai_guardrails.scan.claude_config import get_user_email, load_claude_config - - -def test_load_claude_config_valid(fs: FakeFilesystem) -> None: - """Test loading a valid ~/.claude.json file.""" - config = {'oauthAccount': {'emailAddress': 'user@example.com'}} - config_path = Path.home() / '.claude.json' - fs.create_file(config_path, contents=json.dumps(config)) - - result = load_claude_config(config_path) - assert result == config - - -def test_load_claude_config_missing_file(fs: FakeFilesystem) -> None: - """Test loading when ~/.claude.json does not exist.""" - fs.create_dir(Path.home()) - config_path = Path.home() / '.claude.json' - - result = load_claude_config(config_path) - assert result is None - - -def test_load_claude_config_corrupt_file(fs: FakeFilesystem) -> None: - """Test loading when ~/.claude.json contains invalid JSON.""" - config_path = Path.home() / '.claude.json' - fs.create_file(config_path, contents='not valid json {{{') - - result = load_claude_config(config_path) - assert result is None - - -def test_get_user_email_present() -> None: - """Test extracting email when oauthAccount.emailAddress exists.""" - config = {'oauthAccount': {'emailAddress': 'user@example.com'}} - assert get_user_email(config) == 'user@example.com' - - -def test_get_user_email_missing_oauth_account() -> None: - """Test extracting email when oauthAccount key is missing.""" - config = {'someOtherKey': 'value'} - assert get_user_email(config) is None - - -def test_get_user_email_missing_email_address() -> None: - """Test extracting email when oauthAccount exists but emailAddress is missing.""" - config = {'oauthAccount': {'someOtherField': 'value'}} - assert get_user_email(config) is None diff --git a/tests/cli/commands/ai_guardrails/test_command_utils.py b/tests/cli/commands/ai_guardrails/test_command_utils.py deleted file mode 100644 index 5d8d224b..00000000 --- a/tests/cli/commands/ai_guardrails/test_command_utils.py +++ /dev/null @@ -1,60 +0,0 @@ -"""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 - 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: - """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 diff --git a/tests/cli/commands/ai_guardrails/test_hooks_manager.py b/tests/cli/commands/ai_guardrails/test_hooks_manager.py index a5732bca..4ff8d575 100644 --- a/tests/cli/commands/ai_guardrails/test_hooks_manager.py +++ b/tests/cli/commands/ai_guardrails/test_hooks_manager.py @@ -1,4 +1,4 @@ -"""Tests for AI guardrails hooks manager.""" +"""Tests for AI guardrails hooks manager and per-IDE hooks rendering.""" from pathlib import Path @@ -8,27 +8,22 @@ from cycode.cli.apps.ai_guardrails.consts import ( CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND, - AIIDEType, PolicyMode, - get_hooks_config, ) from cycode.cli.apps.ai_guardrails.hooks_manager import create_policy_file, is_cycode_hook_entry +from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode +from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor 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 + """Detect Cycode hook in Cursor's flat command format.""" + assert is_cycode_hook_entry({'command': 'cycode ai-guardrails scan'}) is True + assert is_cycode_hook_entry({'command': 'cycode ai-guardrails scan --some-flag'}) 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'}], - } + """Detect Cycode hook in Claude Code's nested format.""" + entry = {'hooks': [{'type': 'command', 'command': 'cycode ai-guardrails scan --ide claude-code'}]} assert is_cycode_hook_entry(entry) is True entry = { @@ -39,57 +34,44 @@ def test_is_cycode_hook_entry_claude_code_format() -> None: 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 + """Non-Cycode hooks must not be detected.""" + assert is_cycode_hook_entry({'command': 'some-other-command'}) is False + assert is_cycode_hook_entry({'hooks': [{'type': 'command', 'command': 'some-other-command'}]}) is False + assert is_cycode_hook_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 + """Detection is substring-based: full paths and trailing flags still count.""" + assert is_cycode_hook_entry({'command': '/usr/local/bin/cycode ai-guardrails scan'}) is True + assert is_cycode_hook_entry({'command': 'cycode ai-guardrails scan --verbose'}) is True - entry = {'command': 'cycode ai-guardrails scan --verbose'} - assert is_cycode_hook_entry(entry) is True + +# Per-IDE hook config tests (now exposed via IDE.render_hooks_config) -def test_get_hooks_config_cursor_sync() -> None: - """Test Cursor hooks config in default (sync) mode.""" - config = get_hooks_config(AIIDEType.CURSOR) - hooks = config['hooks'] - scan_hooks = {k: v for k, v in hooks.items() if k != 'sessionStart'} +def test_cursor_render_hooks_sync() -> None: + """Cursor sync hooks: no '&' in scan commands.""" + config = Cursor().render_hooks_config() + scan_hooks = {k: v for k, v in config['hooks'].items() if k != 'sessionStart'} for entries in scan_hooks.values(): for entry in entries: assert entry['command'] == CYCODE_SCAN_PROMPT_COMMAND assert '&' not in entry['command'] -def test_get_hooks_config_cursor_async() -> None: - """Test Cursor hooks config in async mode appends & to command.""" - config = get_hooks_config(AIIDEType.CURSOR, async_mode=True) - hooks = config['hooks'] - scan_hooks = {k: v for k, v in hooks.items() if k != 'sessionStart'} +def test_cursor_render_hooks_async() -> None: + """Cursor async hooks: '&' suffix on scan commands.""" + config = Cursor().render_hooks_config(async_mode=True) + scan_hooks = {k: v for k, v in config['hooks'].items() if k != 'sessionStart'} for entries in scan_hooks.values(): for entry in entries: assert entry['command'].endswith('&') assert CYCODE_SCAN_PROMPT_COMMAND in entry['command'] -def test_get_hooks_config_cursor_session_start() -> None: - """Test Cursor hooks config includes sessionStart with --ide flag.""" - config = get_hooks_config(AIIDEType.CURSOR) +def test_cursor_render_hooks_session_start() -> None: + """Cursor session_start carries the --ide flag explicitly.""" + config = Cursor().render_hooks_config() assert 'sessionStart' in config['hooks'] entries = config['hooks']['sessionStart'] assert len(entries) == 1 @@ -97,9 +79,9 @@ def test_get_hooks_config_cursor_session_start() -> None: assert '--ide cursor' in entries[0]['command'] -def test_get_hooks_config_claude_code_sync() -> None: - """Test Claude Code hooks config in default (sync) mode.""" - config = get_hooks_config(AIIDEType.CLAUDE_CODE) +def test_claude_code_render_hooks_sync() -> None: + """Claude Code sync hooks: no async/timeout fields.""" + config = ClaudeCode().render_hooks_config() scan_events = {k: v for k, v in config['hooks'].items() if k != 'SessionStart'} for event_entries in scan_events.values(): for event_entry in event_entries: @@ -108,9 +90,9 @@ def test_get_hooks_config_claude_code_sync() -> None: assert 'timeout' not in hook -def test_get_hooks_config_claude_code_async() -> None: - """Test Claude Code hooks config in async mode adds async and timeout.""" - config = get_hooks_config(AIIDEType.CLAUDE_CODE, async_mode=True) +def test_claude_code_render_hooks_async() -> None: + """Claude Code async hooks: 'async' flag + timeout.""" + config = ClaudeCode().render_hooks_config(async_mode=True) scan_events = {k: v for k, v in config['hooks'].items() if k != 'SessionStart'} for event_entries in scan_events.values(): for event_entry in event_entries: @@ -118,9 +100,9 @@ def test_get_hooks_config_claude_code_async() -> None: assert hook['async'] is True -def test_get_hooks_config_claude_code_session_start() -> None: - """Test Claude Code hooks config includes SessionStart with --ide flag.""" - config = get_hooks_config(AIIDEType.CLAUDE_CODE) +def test_claude_code_render_hooks_session_start() -> None: + """Claude Code SessionStart carries the --ide flag explicitly.""" + config = ClaudeCode().render_hooks_config() assert 'SessionStart' in config['hooks'] entries = config['hooks']['SessionStart'] assert len(entries) == 1 @@ -128,8 +110,11 @@ def test_get_hooks_config_claude_code_session_start() -> None: assert '--ide claude-code' in entries[0]['hooks'][0]['command'] +# Policy file tests + + def test_create_policy_file_warn(fs: FakeFilesystem) -> None: - """Test creating warn-mode policy file.""" + """Create a warn-mode policy file.""" fs.create_dir(Path.home()) success, message = create_policy_file('user', PolicyMode.WARN) @@ -138,13 +123,11 @@ def test_create_policy_file_warn(fs: FakeFilesystem) -> None: policy_path = Path.home() / '.cycode' / 'ai-guardrails.yaml' assert policy_path.exists() - - policy = yaml.safe_load(policy_path.read_text()) - assert policy['mode'] == 'warn' + assert yaml.safe_load(policy_path.read_text())['mode'] == 'warn' def test_create_policy_file_block(fs: FakeFilesystem) -> None: - """Test creating block-mode policy file.""" + """Create a block-mode policy file.""" fs.create_dir(Path.home()) success, message = create_policy_file('user', PolicyMode.BLOCK) @@ -152,12 +135,11 @@ def test_create_policy_file_block(fs: FakeFilesystem) -> None: assert 'block mode' in message policy_path = Path.home() / '.cycode' / 'ai-guardrails.yaml' - policy = yaml.safe_load(policy_path.read_text()) - assert policy['mode'] == 'block' + assert yaml.safe_load(policy_path.read_text())['mode'] == 'block' def test_create_policy_file_updates_existing(fs: FakeFilesystem) -> None: - """Test that re-running only updates mode and preserves other customizations.""" + """Re-running updates only the mode field and preserves customizations.""" policy_dir = Path.home() / '.cycode' fs.create_dir(policy_dir) policy_path = policy_dir / 'ai-guardrails.yaml' @@ -172,15 +154,13 @@ def test_create_policy_file_updates_existing(fs: FakeFilesystem) -> None: def test_create_policy_file_repo_scope(fs: FakeFilesystem) -> None: - """Test creating policy file in repo scope.""" + """Create a policy file in repo scope.""" repo_path = Path('/my-repo') fs.create_dir(repo_path) - success, message = create_policy_file('repo', PolicyMode.WARN, repo_path=repo_path) + success, _ = create_policy_file('repo', PolicyMode.WARN, repo_path=repo_path) assert success is True policy_path = repo_path / '.cycode' / 'ai-guardrails.yaml' assert policy_path.exists() - - policy = yaml.safe_load(policy_path.read_text()) - assert policy['mode'] == 'warn' + assert yaml.safe_load(policy_path.read_text())['mode'] == 'warn' diff --git a/tests/cli/commands/ai_guardrails/test_session_start_command.py b/tests/cli/commands/ai_guardrails/test_session_start_command.py index 82a13043..0ae57226 100644 --- a/tests/cli/commands/ai_guardrails/test_session_start_command.py +++ b/tests/cli/commands/ai_guardrails/test_session_start_command.py @@ -9,6 +9,8 @@ import typer from cycode.cli.apps.ai_guardrails import session_start_command as _session_start_mod +from cycode.cli.apps.ai_guardrails.ides import claude_code as _claude_mod +from cycode.cli.apps.ai_guardrails.ides import cursor as _cursor_mod from cycode.cli.apps.ai_guardrails.session_start_command import session_start_command @@ -112,8 +114,8 @@ def test_invalid_json_stdin_skips_session_init( # Conversation creation tests -@patch.object(_session_start_mod, 'extract_from_claude_transcript') -@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_claude_mod, 'extract_from_claude_transcript') +@patch.object(_claude_mod, 'load_claude_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') def test_claude_code_creates_conversation( @@ -176,7 +178,7 @@ def test_cursor_creates_conversation( assert call_payload.ide_provider == 'cursor' -@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_claude_mod, 'load_claude_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') def test_conversation_creation_failure_non_blocking( @@ -203,8 +205,8 @@ def test_conversation_creation_failure_non_blocking( # MCP server reporting tests -@patch.object(_session_start_mod, 'load_claude_settings') -@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_claude_mod, 'load_claude_settings') +@patch.object(_claude_mod, 'load_claude_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') def test_claude_code_reports_mcp_servers( @@ -238,8 +240,8 @@ def test_claude_code_reports_mcp_servers( ) -@patch.object(_session_start_mod, 'load_claude_settings') -@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_claude_mod, 'load_claude_settings') +@patch.object(_claude_mod, 'load_claude_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') def test_claude_code_merges_plugin_mcp_servers_and_metadata( @@ -298,8 +300,8 @@ def test_claude_code_merges_plugin_mcp_servers_and_metadata( ) -@patch.object(_session_start_mod, 'load_claude_settings') -@patch.object(_session_start_mod, 'load_claude_config') +@patch.object(_claude_mod, 'load_claude_settings') +@patch.object(_claude_mod, 'load_claude_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') def test_claude_code_no_mcp_servers_no_plugins_skips_report( @@ -324,7 +326,7 @@ def test_claude_code_no_mcp_servers_no_plugins_skips_report( mock_ai_client.report_session_context.assert_not_called() -@patch.object(_session_start_mod, 'load_cursor_config') +@patch.object(_cursor_mod, '_load_cursor_mcp_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') def test_cursor_reports_mcp_servers( @@ -350,7 +352,7 @@ def test_cursor_reports_mcp_servers( ) -@patch.object(_session_start_mod, 'load_cursor_config') +@patch.object(_cursor_mod, '_load_cursor_mcp_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') def test_cursor_no_mcp_servers_skips_report( From 3d152224d199bd2f8f4ddfda6b877d241afbc3bb Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Wed, 27 May 2026 16:29:26 +0300 Subject: [PATCH 297/347] CM-62984 add codex cli support (#461) Co-authored-by: Claude Opus 4.7 (1M context) --- .../cli/apps/ai_guardrails/hooks_manager.py | 108 ++++-- .../cli/apps/ai_guardrails/ides/__init__.py | 3 +- .../apps/ai_guardrails/ides/_plugin_utils.py | 73 ++++ cycode/cli/apps/ai_guardrails/ides/base.py | 20 ++ .../apps/ai_guardrails/ides/claude_code.py | 84 ++--- cycode/cli/apps/ai_guardrails/ides/codex.py | 310 ++++++++++++++++ cycode/cli/apps/ai_guardrails/ides/cursor.py | 2 +- .../cli/apps/ai_guardrails/scan/handlers.py | 88 +++-- cycode/cli/utils/jwt_utils.py | 8 + poetry.lock | 21 +- pyproject.toml | 2 + .../commands/ai_guardrails/ides/test_codex.py | 335 ++++++++++++++++++ .../ai_guardrails/ides/test_contract.py | 2 +- .../ai_guardrails/test_hooks_manager.py | 111 +++++- 14 files changed, 1058 insertions(+), 109 deletions(-) create mode 100644 cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py create mode 100644 cycode/cli/apps/ai_guardrails/ides/codex.py create mode 100644 tests/cli/commands/ai_guardrails/ides/test_codex.py diff --git a/cycode/cli/apps/ai_guardrails/hooks_manager.py b/cycode/cli/apps/ai_guardrails/hooks_manager.py index 1fe23bb2..192bb9f3 100644 --- a/cycode/cli/apps/ai_guardrails/hooks_manager.py +++ b/cycode/cli/apps/ai_guardrails/hooks_manager.py @@ -28,7 +28,7 @@ def _is_cycode_command(command: str) -> bool: def is_cycode_hook_entry(entry: dict) -> bool: - """Detect Cycode hook entries in both Cursor (flat) and Claude Code (nested) shapes.""" + """True if any hook inside ``entry`` is owned by Cycode.""" command = entry.get('command', '') if _is_cycode_command(command): return True @@ -40,6 +40,31 @@ def is_cycode_hook_entry(entry: dict) -> bool: return False +def _strip_cycode_from_entry(entry: dict) -> Optional[dict]: + """Remove Cycode hooks from ``entry`` and return the remainder. + + Returns ``None`` when nothing useful remains (Cursor-flat Cycode entry, or + every nested hook was Cycode). Non-Cycode hooks co-located in the same + entry are preserved. + """ + # Cursor format: the entry itself IS a single hook command. + if 'command' in entry and 'hooks' not in entry: + return None if _is_cycode_command(entry.get('command', '')) else entry + + # Claude Code / Codex format: nested `hooks` list inside the entry. + nested = entry.get('hooks') + if isinstance(nested, list): + kept = [h for h in nested if not (isinstance(h, dict) and _is_cycode_command(h.get('command', '')))] + if not kept: + return None + if len(kept) == len(nested): + return entry # nothing Cycode-shaped inside; preserve identity + return {**entry, 'hooks': kept} + + # Entry has neither shape we recognize — leave it alone defensively. + return entry + + def _load_hooks_file(hooks_path: Path) -> Optional[dict]: if not hooks_path.exists(): return None @@ -108,50 +133,83 @@ def install_hooks( for event, entries in rendered['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 + existing['hooks'][event] = [ + stripped for e in existing['hooks'][event] if (stripped := _strip_cycode_from_entry(e)) is not None + ] for entry in entries: existing['hooks'][event].append(entry) - 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}' + if not _save_hooks_file(hooks_path, existing): + return False, f'Failed to install hooks to {hooks_path}' + message = f'AI guardrails hooks installed: {hooks_path}' -def uninstall_hooks(ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None) -> tuple[bool, str]: - """Remove Cycode AI guardrails hooks for ``ide``.""" - hooks_path = ide.settings_path(scope, repo_path) + # IDE-specific extras (e.g. Codex enables a TOML feature flag). + extra_ok, extra_message = ide.post_install(scope, repo_path) + if not extra_ok: + return False, extra_message + if extra_message: + message = f'{message}\n {extra_message}' - existing = _load_hooks_file(hooks_path) - if existing is None: - return True, f'No hooks file found at {hooks_path}' + return True, message + +def _strip_cycode_entries(existing: dict) -> bool: + """Mutate ``existing`` to drop Cycode hooks (surgically). Return True if anything changed.""" 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 - if not existing['hooks'][event]: + before = existing['hooks'][event] + after: list = [] + for e in before: + stripped = _strip_cycode_from_entry(e) + if stripped is None: + modified = True + continue + if stripped is not e: + modified = True + after.append(stripped) + if not after: del existing['hooks'][event] + else: + existing['hooks'][event] = after + return modified + +def _persist_uninstall(hooks_path: Path, existing: dict, modified: bool) -> tuple[bool, str]: + """Apply the uninstall result to disk and return ``(success, message)``.""" if not modified: return True, 'No Cycode hooks found to remove' - 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}' + return True, f'Removed hooks file: {hooks_path}' + if not _save_hooks_file(hooks_path, existing): + return False, f'Failed to update hooks file: {hooks_path}' + return True, f'Cycode hooks removed from: {hooks_path}' + + +def uninstall_hooks(ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None) -> tuple[bool, str]: + """Remove Cycode AI guardrails hooks for ``ide``.""" + hooks_path = ide.settings_path(scope, repo_path) + + existing = _load_hooks_file(hooks_path) + if existing is None: + return True, f'No hooks file found at {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}' + modified = _strip_cycode_entries(existing) + file_ok, message = _persist_uninstall(hooks_path, existing, modified) + if not file_ok: + return False, message + + extra_ok, extra_message = ide.post_uninstall(scope, repo_path) + if not extra_ok: + return False, extra_message + if extra_message: + message = f'{message}\n {extra_message}' + return True, message def get_hooks_status(ide: IDE, scope: str = 'user', repo_path: Optional[Path] = None) -> dict: diff --git a/cycode/cli/apps/ai_guardrails/ides/__init__.py b/cycode/cli/apps/ai_guardrails/ides/__init__.py index 92859701..e598c5a5 100644 --- a/cycode/cli/apps/ai_guardrails/ides/__init__.py +++ b/cycode/cli/apps/ai_guardrails/ides/__init__.py @@ -9,11 +9,12 @@ from cycode.cli.apps.ai_guardrails.ides.base import IDE from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode +from cycode.cli.apps.ai_guardrails.ides.codex import Codex from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor # Single source of truth: name → singleton instance. # `--ide` choices and install/uninstall/status iteration both derive from this. -IDES: dict[str, IDE] = {ide.name: ide for ide in (Cursor(), ClaudeCode())} +IDES: dict[str, IDE] = {ide.name: ide for ide in (Cursor(), ClaudeCode(), Codex())} # Default IDE used when `--ide` is omitted. Kept here so the value is colocated # with the registry; no module outside `ides/` needs to know which IDE wins. diff --git a/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py b/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py new file mode 100644 index 00000000..186dd37f --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py @@ -0,0 +1,73 @@ +"""Shared plugin-resolution helpers for IDE integrations. + +Both Claude Code and Codex use the same ``@`` key convention +and emit the same telemetry shape — only the marketplace layout and manifest +location differ. ``walk_enabled_plugins`` is the IDE-agnostic loop; each IDE +supplies the two callables that vary (``locate_dir`` + ``read_plugin``). +""" + +import json +from pathlib import Path +from typing import Any, Callable, Optional + +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Plugins') + + +def load_plugin_json(path: Path) -> Optional[dict]: + """Load a JSON file inside a plugin directory; None if missing or invalid.""" + if not path.exists(): + return None + try: + return json.loads(path.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load plugin file, %s', {'path': str(path)}, exc_info=e) + return None + + +def walk_enabled_plugins( + plugin_entries: dict[str, Any], + is_enabled: Callable[[Any], bool], + locate_dir: Callable[[str, str], Optional[Path]], + read_plugin: Callable[[Path], tuple[dict, dict]], +) -> tuple[dict, dict]: + """Iterate enabled plugins; merge their MCP servers and metadata. + + Args: + plugin_entries: ``{@: settings}`` map from the IDE config. + is_enabled: returns True if ``settings`` indicates the plugin is on + (e.g. ``bool(settings)`` for Claude, ``settings.get('enabled')`` for Codex). + locate_dir: given ``(plugin_name, marketplace)``, returns the plugin's + filesystem path or None if it can't be resolved. + read_plugin: given the plugin path, returns ``(entry_fields, servers)``: + ``entry_fields`` are extra metadata to attach to the inventory entry + (name/version/description/...), ``servers`` are MCP servers contributed. + + Returns ``(merged_mcp_servers, enriched_plugins)``. Plugin keys without + ``@`` (or that fail to resolve to a directory) still appear in the + inventory with just ``{'enabled': True}`` so we don't silently drop them. + """ + merged_mcp: dict = {} + enriched: dict = {} + + for plugin_key, settings in plugin_entries.items(): + if not is_enabled(settings): + continue + + entry: dict = {'enabled': True} + enriched[plugin_key] = entry + + if '@' not in plugin_key: + continue + plugin_name, marketplace = plugin_key.split('@', 1) + + plugin_dir = locate_dir(plugin_name, marketplace) + if plugin_dir is None: + continue + + plugin_fields, servers = read_plugin(plugin_dir) + entry.update(plugin_fields) + merged_mcp.update(servers) + + return merged_mcp, enriched diff --git a/cycode/cli/apps/ai_guardrails/ides/base.py b/cycode/cli/apps/ai_guardrails/ides/base.py index 55d5fb05..92065590 100644 --- a/cycode/cli/apps/ai_guardrails/ides/base.py +++ b/cycode/cli/apps/ai_guardrails/ides/base.py @@ -108,6 +108,26 @@ def render_hooks_config(self, async_mode: bool = False) -> dict: ``hooks_manager`` can treat them uniformly. """ + def post_install(self, scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]: + """Run IDE-specific actions after the hooks file is written. + + Default: no-op success. Override to perform extra setup that doesn't + belong in the hooks file itself — e.g. Codex enables a + ``[features] codex_hooks = true`` flag in its TOML config. + + Returns ``(success, message)``. If ``success`` is False, the overall + install is considered failed. + """ + return True, '' + + def post_uninstall(self, scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]: + """Run IDE-specific cleanup after the hooks file is removed. + + Default: no-op success. Override to undo whatever ``post_install`` + wrote outside the hooks file. + """ + return True, '' + # --- runtime scan --- @abstractmethod diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py index 519914b9..4178ec56 100644 --- a/cycode/cli/apps/ai_guardrails/ides/claude_code.py +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -7,6 +7,7 @@ from typing import ClassVar, Optional from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND +from cycode.cli.apps.ai_guardrails.ides._plugin_utils import load_plugin_json, walk_enabled_plugins from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @@ -127,7 +128,7 @@ def load_claude_config(config_path: Optional[Path] = None) -> Optional[dict]: """Load and parse `~/.claude.json`. Returns None if missing/invalid.""" path = config_path or _CLAUDE_CONFIG_PATH if not path.exists(): - logger.debug('Claude config file not found', extra={'path': str(path)}) + logger.debug('Claude config file not found, %s', {'path': str(path)}) return None try: return json.loads(path.read_text(encoding='utf-8')) @@ -150,7 +151,7 @@ def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict] """Load and parse `~/.claude/settings.json`. Returns None if missing/invalid.""" path = settings_path or _CLAUDE_SETTINGS_PATH if not path.exists(): - logger.debug('Claude settings file not found', extra={'path': str(path)}) + logger.debug('Claude settings file not found, %s', {'path': str(path)}) return None try: return json.loads(path.read_text(encoding='utf-8')) @@ -171,69 +172,47 @@ def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]: return path if path.is_dir() else None -def _load_plugin_json_file(plugin_path: Path, relative_path: str) -> Optional[dict]: - """Load and parse a JSON file inside a plugin directory. +def _read_claude_plugin(plugin_dir: Path) -> tuple[dict, dict]: + """Read one Claude Code plugin's manifest + MCP servers. - Returns None if the file is missing, unreadable, or has invalid JSON. + Claude hardcodes the MCP file at ``/.mcp.json`` and always + wraps it as ``{"mcpServers": {...}}``. """ - target = plugin_path / relative_path - if not target.exists(): - return None - try: - return json.loads(target.read_text(encoding='utf-8')) - except Exception as e: - logger.debug('Failed to load plugin file', extra={'path': str(target)}, exc_info=e) - return None + manifest = load_plugin_json(plugin_dir / '.claude-plugin' / 'plugin.json') or {} + entry: dict = {} + for field in ('name', 'version', 'description'): + if field in manifest: + entry[field] = manifest[field] + mcp_config = load_plugin_json(plugin_dir / '.mcp.json') or {} + servers: dict = mcp_config.get('mcpServers') or {} + if servers: + entry['mcp_server_names'] = list(servers.keys()) + return entry, servers -def resolve_plugins(settings: dict) -> tuple[dict, dict]: - """Resolve enabled plugins to their MCP servers and metadata. - Walks ``enabledPlugins`` from claude settings, resolves each plugin's - marketplace directory via ``extraKnownMarketplaces``, and reads: - - ``/.mcp.json`` for MCP servers (merged into a flat dict) - - ``/.claude-plugin/plugin.json`` for metadata (name, version, description) +def resolve_plugins(settings: dict) -> tuple[dict, dict]: + """Walk Claude Code's ``enabledPlugins`` via the shared plugin walker. - Returns ``(merged_mcp_servers, enriched_plugins)``. + Each enabled plugin's marketplace is resolved through + ``extraKnownMarketplaces`` to a directory; the rest of the work + (manifest + ``.mcp.json``) is the shared ``_read_claude_plugin``. """ enabled = settings.get('enabledPlugins') or {} marketplaces = settings.get('extraKnownMarketplaces') or {} - merged_mcp: dict = {} - enriched: dict = {} - for plugin_key, is_enabled in enabled.items(): - if not is_enabled: - continue - - entry: dict = {'enabled': True} - enriched[plugin_key] = entry - - if '@' not in plugin_key: - continue - - _plugin_name, marketplace_name = plugin_key.split('@', 1) + def _locate(_plugin_name: str, marketplace_name: str) -> Optional[Path]: marketplace = marketplaces.get(marketplace_name) if not marketplace: - continue - - plugin_path = _resolve_marketplace_path(marketplace) - if plugin_path is None: - continue - - metadata = _load_plugin_json_file(plugin_path, '.claude-plugin/plugin.json') or {} - for field in ('name', 'version', 'description'): - if field in metadata: - entry[field] = metadata[field] - - mcp_config = _load_plugin_json_file(plugin_path, '.mcp.json') or {} - plugin_server_names = [] - for server_name, server_cfg in (mcp_config.get('mcpServers') or {}).items(): - merged_mcp[server_name] = server_cfg - plugin_server_names.append(server_name) - if plugin_server_names: - entry['mcp_server_names'] = plugin_server_names + return None + return _resolve_marketplace_path(marketplace) - return merged_mcp, enriched + return walk_enabled_plugins( + plugin_entries=enabled, + is_enabled=bool, + locate_dir=_locate, + read_plugin=_read_claude_plugin, + ) # --- IDE integration ---------------------------------------------------------- @@ -260,6 +239,7 @@ def render_hooks_config(self, async_mode: bool = False) -> dict: 'hooks': { 'SessionStart': [ { + 'matcher': 'startup|clear', 'hooks': [{'type': 'command', 'command': _SESSION_START_COMMAND}], } ], diff --git a/cycode/cli/apps/ai_guardrails/ides/codex.py b/cycode/cli/apps/ai_guardrails/ides/codex.py new file mode 100644 index 00000000..f8c9b04d --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/codex.py @@ -0,0 +1,310 @@ +"""Codex CLI IDE integration for AI guardrails.""" + +import json +import os +import sys +from pathlib import Path +from typing import ClassVar, Optional + +import tomli_w + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover - py<3.11 fallback + import tomli as tomllib + +from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND +from cycode.cli.apps.ai_guardrails.ides._plugin_utils import load_plugin_json, walk_enabled_plugins +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType +from cycode.cli.utils.jwt_utils import decode_jwt_unverified +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Codex') + +_CONFIG_DIR_NAME = '.codex' +_HOOKS_FILE_NAME = 'hooks.json' +_CONFIG_TOML_NAME = 'config.toml' +_AUTH_JSON_NAME = 'auth.json' +_CODEX_HOME_ENV_VAR = 'CODEX_HOME' + +_HOOK_EVENTS = ('UserPromptSubmit', 'PreToolUse:mcp') +_CODEX_EVENT_NAMES = frozenset(e.split(':', 1)[0] for e in _HOOK_EVENTS) + +_SCAN_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide codex' +_SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide codex' + + +def _codex_home() -> Path: + """Resolve Codex's user-scope home directory. + + Honors ``$CODEX_HOME`` per Codex's documented override; falls back to + ``~/.codex``. + """ + override = os.environ.get(_CODEX_HOME_ENV_VAR) + if override: + return Path(override) + return Path.home() / _CONFIG_DIR_NAME + + +def _codex_config_toml_path(scope: str, repo_path: Optional[Path] = None) -> Path: + """Return the Codex ``config.toml`` path for the given scope.""" + if scope == 'repo' and repo_path: + return repo_path / _CONFIG_DIR_NAME / _CONFIG_TOML_NAME + return _codex_home() / _CONFIG_TOML_NAME + + +def _load_codex_config(config_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse Codex's ``config.toml``. Returns None on missing/invalid.""" + path = config_path or (_codex_home() / _CONFIG_TOML_NAME) + if not path.exists(): + logger.debug('Codex config file not found, %s', {'path': str(path)}) + return None + try: + with path.open('rb') as f: + return tomllib.load(f) + except Exception as e: + logger.debug('Failed to load Codex config file, %s', {'path': str(path)}, exc_info=e) + return None + + +def _email_from_auth(auth_path: Optional[Path] = None) -> Optional[str]: + """Best-effort extraction of the signed-in Codex user's email. + + Reads ``~/.codex/auth.json`` and decodes the JWT in ``tokens.id_token`` + to pull the ``email`` claim. Returns None if auth.json is missing + (``OPENAI_API_KEY``-only setups, OS keychain credentials) or unreadable. + """ + path = auth_path or (_codex_home() / _AUTH_JSON_NAME) + if not path.exists(): + logger.debug('Codex auth file not found, %s', {'path': str(path)}) + return None + try: + auth = json.loads(path.read_text(encoding='utf-8')) + except (OSError, json.JSONDecodeError) as e: + logger.debug('Failed to load Codex auth file, %s', {'path': str(path)}, exc_info=e) + return None + + token = (auth.get('tokens') or {}).get('id_token') + if not token: + return None + claims = decode_jwt_unverified(token) + if not claims: + return None + return claims.get('email') + + +def _resolve_codex_plugin_dir(plugin_name: str, marketplace: str) -> Optional[Path]: + """Find ``~/.codex/plugins/cache////``. + + The trailing segment is a content hash. If multiple are cached, pick the + most recently modified. + """ + base = _codex_home() / 'plugins' / 'cache' / marketplace / plugin_name + if not base.is_dir(): + return None + candidates = [d for d in base.iterdir() if d.is_dir()] + if not candidates: + return None + return max(candidates, key=lambda d: d.stat().st_mtime) + + +def _read_codex_plugin(plugin_dir: Path) -> tuple[dict, dict]: + """Read one Codex plugin's manifest + MCP servers. + + Codex's manifest references the MCP file via a path string in the + ``mcpServers`` field (default ``./.mcp.json``); the target file is either + a bare ``{name: cfg}`` map or wrapped in ``{"mcpServers": {...}}``. + """ + manifest = load_plugin_json(plugin_dir / '.codex-plugin' / 'plugin.json') + entry: dict = {} + if not manifest: + return entry, {} + + for field in ('name', 'version', 'description'): + if field in manifest: + entry[field] = manifest[field] + + mcp_ref = manifest.get('mcpServers') + if not mcp_ref: + return entry, {} + mcp_doc = load_plugin_json(plugin_dir / mcp_ref) or {} + servers = mcp_doc.get('mcpServers', mcp_doc) + if not isinstance(servers, dict): + servers = {} + if servers: + entry['mcp_server_names'] = list(servers.keys()) + return entry, servers + + +def _resolve_codex_plugins(config: dict) -> tuple[dict, dict]: + """Walk enabled ``[plugins."@"]`` entries.""" + return walk_enabled_plugins( + plugin_entries=config.get('plugins') or {}, + is_enabled=lambda s: isinstance(s, dict) and bool(s.get('enabled')), + locate_dir=_resolve_codex_plugin_dir, + read_plugin=_read_codex_plugin, + ) + + +def _enable_codex_hooks_feature(scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]: + """Set ``[features] hooks = true`` in Codex's ``config.toml``. + + Codex's hook scripts are gated behind this feature flag. We preserve any + existing keys and create the file (+ parent dir) when missing. + """ + config_path = _codex_config_toml_path(scope, repo_path) + + config: dict = {} + if config_path.exists(): + try: + with config_path.open('rb') as f: + config = tomllib.load(f) + except Exception as e: + logger.error('Failed to parse Codex config.toml, %s', {'path': str(config_path)}, exc_info=e) + return False, f'Failed to parse existing Codex config at {config_path}' + + features = config.get('features') + if not isinstance(features, dict): + features = {} + features['hooks'] = True + config['features'] = features + + try: + config_path.parent.mkdir(parents=True, exist_ok=True) + with config_path.open('wb') as f: + tomli_w.dump(config, f) + return True, f'Enabled hooks feature in {config_path}' + except Exception as e: + logger.error('Failed to write Codex config.toml, %s', {'path': str(config_path)}, exc_info=e) + return False, f'Failed to write Codex config at {config_path}' + + +class Codex(IDE): + name: ClassVar[str] = 'codex' + display_name: ClassVar[str] = 'Codex' + hook_events: ClassVar[list[str]] = list(_HOOK_EVENTS) + + def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: + if scope == 'repo' and repo_path: + return repo_path / _CONFIG_DIR_NAME / _HOOKS_FILE_NAME + return _codex_home() / _HOOKS_FILE_NAME + + def render_hooks_config(self, async_mode: bool = False) -> dict: + # Codex's TOML `async: true` flag is unimplemented; shell-background via + # `&` is the working mechanism. SessionStart stays sync so the + # conversation context is registered before any scan hook fires. + bg = ' &' if async_mode else '' + scan_cmd = f'{_SCAN_COMMAND}{bg}' + return { + 'hooks': { + 'SessionStart': [ + { + 'matcher': 'startup|clear', + 'hooks': [{'type': 'command', 'command': _SESSION_START_COMMAND}], + } + ], + 'UserPromptSubmit': [ + { + 'hooks': [{'type': 'command', 'command': scan_cmd}], + } + ], + 'PreToolUse': [ + { + 'matcher': 'mcp__.*', + 'hooks': [{'type': 'command', 'command': scan_cmd}], + }, + ], + }, + } + + def post_install(self, scope: str, repo_path: Optional[Path] = None) -> tuple[bool, str]: + return _enable_codex_hooks_feature(scope, repo_path) + + def matches_payload(self, raw_payload: dict) -> bool: + return raw_payload.get('hook_event_name', '') in _CODEX_EVENT_NAMES + + def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: + hook_event_name = raw_payload.get('hook_event_name', '') + tool_name = raw_payload.get('tool_name', '') + tool_input = raw_payload.get('tool_input') + + if hook_event_name == 'UserPromptSubmit': + canonical_event: AiHookEventType | str = AiHookEventType.PROMPT + elif hook_event_name == 'PreToolUse' and tool_name.startswith('mcp__'): + canonical_event = AiHookEventType.MCP_EXECUTION + else: + canonical_event = hook_event_name + + mcp_server_name = None + mcp_tool_name = None + mcp_arguments = 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] + mcp_arguments = tool_input + + return AIHookPayload( + event_name=canonical_event, + conversation_id=raw_payload.get('session_id'), + generation_id=raw_payload.get('turn_id'), + ide_user_email=_email_from_auth(), + model=raw_payload.get('model'), + ide_provider=self.name, + prompt=raw_payload.get('prompt', ''), + mcp_server_name=mcp_server_name, + mcp_tool_name=mcp_tool_name, + mcp_arguments=mcp_arguments, + ) + + def build_hook_response(self, decision: HookDecision) -> dict: + # Codex accepts the same hook response shapes as Claude Code: + # - PROMPT: empty for allow, {"decision": "block", "reason": ...} for deny + # - PreToolUse: hookSpecificOutput.permissionDecision + if decision.event_type == AiHookEventType.PROMPT: + if decision.action == DecisionAction.ALLOW: + return {} + return {'decision': 'block', 'reason': decision.user_message or ''} + + if decision.action == DecisionAction.ALLOW: + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'allow', + } + } + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': decision.action.value, # 'deny' or 'ask' + 'permissionDecisionReason': decision.user_message or '', + } + } + + def build_session_payload(self, raw_payload: dict) -> AIHookPayload: + return AIHookPayload( + conversation_id=raw_payload.get('session_id'), + ide_user_email=_email_from_auth(), + model=raw_payload.get('model'), + ide_provider=self.name, + ide_version=raw_payload.get('codex_version'), + source=raw_payload.get('source'), + ) + + def get_user_email(self) -> Optional[str]: + return _email_from_auth() + + def get_session_context(self) -> tuple[dict, dict]: + config = _load_codex_config() + if not config: + return {}, {} + # Codex stores MCP servers under `[mcp_servers.]`. Plugin-contributed + # servers (via `[plugins."@"]`) merge on top. + mcp_servers: dict = dict(config.get('mcp_servers') or {}) + plugin_mcp, enriched_plugins = _resolve_codex_plugins(config) + mcp_servers.update(plugin_mcp) + return mcp_servers, enriched_plugins diff --git a/cycode/cli/apps/ai_guardrails/ides/cursor.py b/cycode/cli/apps/ai_guardrails/ides/cursor.py index 4f6be1eb..aa218542 100644 --- a/cycode/cli/apps/ai_guardrails/ides/cursor.py +++ b/cycode/cli/apps/ai_guardrails/ides/cursor.py @@ -43,7 +43,7 @@ def _load_cursor_mcp_config(config_path: Optional[Path] = None) -> Optional[dict """Load and parse `~/.cursor/mcp.json`. Returns None if missing/invalid.""" path = config_path or (Path.home() / '.cursor' / _MCP_CONFIG_FILENAME) if not path.exists(): - logger.debug('Cursor MCP config file not found', extra={'path': str(path)}) + logger.debug('Cursor MCP config file not found, %s', {'path': str(path)}) return None try: return json.loads(path.read_text(encoding='utf-8')) diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 4a56a179..8b8a2d71 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -10,6 +10,7 @@ import json import os +from dataclasses import dataclass from multiprocessing.pool import ThreadPool from multiprocessing.pool import TimeoutError as PoolTimeoutError from typing import Callable, Optional @@ -178,23 +179,44 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: ) -def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision: - """Scan MCP tool arguments for secrets before execution.""" +@dataclass(frozen=True) +class _ArgScanFeature: + """Configuration for a "scan some text and decide" event. + + MCP execution and command exec share identical scan-and-decide logic; + only the policy key, event type, and user-facing messages differ. + """ + + policy_key: str # 'mcp' or 'command_exec' + scan_key: str # 'scan_arguments' or 'scan_command' + event_type: AiHookEventType + block_reason: BlockReason + deny_message: Callable[[str], str] + deny_agent_message: str + ask_message: Callable[[str], str] + ask_agent_message: str + + +def _handle_arg_scan( + ctx: typer.Context, + payload: AIHookPayload, + policy: dict, + feature: _ArgScanFeature, + scan_text: str, +) -> HookDecision: + """Shared scan + decision flow for MCP_EXECUTION and COMMAND_EXEC events.""" ai_client = ctx.obj['ai_security_client'] - mcp_config = get_policy_value(policy, 'mcp', default={}) - if not get_policy_value(mcp_config, 'enabled', default=True): - ai_client.create_event(payload, AiHookEventType.MCP_EXECUTION, AIHookOutcome.ALLOWED) - return HookDecision.allow(AiHookEventType.MCP_EXECUTION) + feature_config = get_policy_value(policy, feature.policy_key, default={}) + if not get_policy_value(feature_config, 'enabled', default=True): + ai_client.create_event(payload, feature.event_type, AIHookOutcome.ALLOWED) + return HookDecision.allow(feature.event_type) 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=PolicyMode.BLOCK) + clipped = truncate_utf8(scan_text, max_bytes) + action = get_policy_value(feature_config, 'action', default=PolicyMode.BLOCK) scan_id = None block_reason = None @@ -202,26 +224,25 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli error_message = None try: - if get_policy_value(mcp_config, 'scan_arguments', default=True): + if get_policy_value(feature_config, feature.scan_key, default=True): violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms) if violation_summary: - block_reason = BlockReason.SECRETS_IN_MCP_ARGS + block_reason = feature.block_reason if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK: outcome = AIHookOutcome.BLOCKED - user_message = f'Cycode blocked MCP tool call "{tool}". {violation_summary}' return HookDecision.deny( - AiHookEventType.MCP_EXECUTION, - user_message, - 'Do not pass secrets to tools. Use secret references (name/id) instead.', + feature.event_type, + feature.deny_message(violation_summary), + feature.deny_agent_message, ) outcome = AIHookOutcome.WARNED return HookDecision.ask( - AiHookEventType.MCP_EXECUTION, - f'{violation_summary} in MCP tool call "{tool}". Allow execution?', - 'Possible secrets detected in tool arguments; proceed with caution.', + feature.event_type, + feature.ask_message(violation_summary), + feature.ask_agent_message, ) - return HookDecision.allow(AiHookEventType.MCP_EXECUTION) + return HookDecision.allow(feature.event_type) except Exception as e: outcome = ( AIHookOutcome.ALLOWED if get_policy_value(policy, 'fail_open', default=True) else AIHookOutcome.BLOCKED @@ -232,7 +253,7 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli finally: ai_client.create_event( payload, - AiHookEventType.MCP_EXECUTION, + feature.event_type, outcome, scan_id=scan_id, block_reason=block_reason, @@ -240,6 +261,29 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli ) +def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, policy: dict) -> HookDecision: + """Scan MCP tool arguments for secrets before execution.""" + tool = payload.mcp_tool_name or 'unknown' + args = payload.mcp_arguments or {} + args_text = args if isinstance(args, str) else json.dumps(args) + return _handle_arg_scan( + ctx, + payload, + policy, + _ArgScanFeature( + policy_key='mcp', + scan_key='scan_arguments', + event_type=AiHookEventType.MCP_EXECUTION, + block_reason=BlockReason.SECRETS_IN_MCP_ARGS, + deny_message=lambda v: f'Cycode blocked MCP tool call "{tool}". {v}', + deny_agent_message='Do not pass secrets to tools. Use secret references (name/id) instead.', + ask_message=lambda v: f'{v} in MCP tool call "{tool}". Allow execution?', + ask_agent_message='Possible secrets detected in tool arguments; proceed with caution.', + ), + scan_text=args_text, + ) + + def get_handler_for_event(event_type: str) -> Optional[HandlerFn]: """Look up the handler for a canonical event type.""" handlers: dict[str, HandlerFn] = { diff --git a/cycode/cli/utils/jwt_utils.py b/cycode/cli/utils/jwt_utils.py index c87b7c48..21f767d0 100644 --- a/cycode/cli/utils/jwt_utils.py +++ b/cycode/cli/utils/jwt_utils.py @@ -5,6 +5,14 @@ _JWT_PAYLOAD_POSSIBLE_USER_ID_FIELD_NAMES = ('userId', 'internalId', 'token-user-id') +def decode_jwt_unverified(token: str) -> Optional[dict]: + """Return JWT claims without signature verification, or None if the token is unreadable.""" + try: + return jwt.decode(token, options={'verify_signature': False}) + except jwt.PyJWTError: + return None + + 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}) diff --git a/poetry.lock b/poetry.lock index 1a6ce6ee..eab3b4da 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.3.4 and should not be changed by hand. [[package]] name = "altgraph" @@ -339,6 +339,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {test = "sys_platform == \"win32\""} [[package]] name = "coverage" @@ -742,7 +743,7 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.03.6" +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" rpds-py = ">=0.7.1" @@ -1875,7 +1876,7 @@ version = "2.3.0" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" -groups = ["test"] +groups = ["main", "test"] markers = "python_version < \"3.11\"" files = [ {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, @@ -1922,6 +1923,18 @@ files = [ {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, ] +[[package]] +name = "tomli-w" +version = "1.2.0" +description = "A lil' TOML writer" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90"}, + {file = "tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021"}, +] + [[package]] name = "typer" version = "0.15.4" @@ -2043,4 +2056,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "b67d2f0ceadcf2fbd351b056596da8a04656a3774c2cc26e13cf678b6f31561f" +content-hash = "ac37763cb9b582d1997853c1347edfdb05566fa225adf304685ecce66989fc67" diff --git a/pyproject.toml b/pyproject.toml index c3d10f0a..1b43757b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,8 @@ 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" +tomli-w = ">=1.0.0,<2.0.0" +tomli = {version = ">=2.0.0,<3.0.0", python = "<3.11"} [tool.poetry.group.test.dependencies] mock = ">=4.0.3,<4.1.0" diff --git a/tests/cli/commands/ai_guardrails/ides/test_codex.py b/tests/cli/commands/ai_guardrails/ides/test_codex.py new file mode 100644 index 00000000..f71e19a2 --- /dev/null +++ b/tests/cli/commands/ai_guardrails/ides/test_codex.py @@ -0,0 +1,335 @@ +"""Codex CLI IDE integration tests.""" + +import base64 +import json +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest +from pyfakefs.fake_filesystem import FakeFilesystem + +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision +from cycode.cli.apps.ai_guardrails.ides.codex import ( + Codex, + _codex_home, + _email_from_auth, + _enable_codex_hooks_feature, + _load_codex_config, +) +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover - py<3.11 fallback + import tomli as tomllib + + +# --- payload parsing --------------------------------------------------------- + + +def test_matches_payload_only_codex_events() -> None: + codex = Codex() + assert codex.matches_payload({'hook_event_name': 'UserPromptSubmit'}) is True + assert codex.matches_payload({'hook_event_name': 'PreToolUse'}) is True + assert codex.matches_payload({'hook_event_name': 'beforeSubmitPrompt'}) is False + assert codex.matches_payload({'hook_event_name': 'SessionStart'}) is False + + +def test_parse_prompt_payload() -> None: + unified = Codex().parse_hook_payload( + { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'turn_id': 'turn-456', + 'model': 'gpt-5-codex', + 'prompt': 'Test prompt', + } + ) + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id == 'session-123' + assert unified.generation_id == 'turn-456' + assert unified.model == 'gpt-5-codex' + assert unified.ide_provider == 'codex' + assert unified.prompt == 'Test prompt' + + +def test_parse_mcp_execution_payload() -> None: + args = {'resource_type': 'merge_request', 'resource_id': '4'} + unified = Codex().parse_hook_payload( + { + 'hook_event_name': 'PreToolUse', + 'tool_name': 'mcp__gitlab__discussion_list', + 'tool_input': args, + } + ) + assert unified.event_name == AiHookEventType.MCP_EXECUTION + assert unified.mcp_server_name == 'gitlab' + assert unified.mcp_tool_name == 'discussion_list' + assert unified.mcp_arguments == args + + +def test_parse_unknown_event_falls_through() -> None: + unified = Codex().parse_hook_payload({'hook_event_name': 'Stop'}) + assert unified.event_name == 'Stop' + + +def test_parse_empty_payload_defaults() -> None: + unified = Codex().parse_hook_payload({'hook_event_name': 'UserPromptSubmit'}) + assert unified.event_name == AiHookEventType.PROMPT + assert unified.prompt == '' + assert unified.ide_provider == 'codex' + + +# --- response building ------------------------------------------------------- + + +def test_build_prompt_responses() -> None: + codex = Codex() + assert codex.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT)) == {} + assert codex.build_hook_response(HookDecision.deny(AiHookEventType.PROMPT, 'no!')) == { + 'decision': 'block', + 'reason': 'no!', + } + + +def test_build_mcp_execution_allow_and_deny() -> None: + codex = Codex() + allow = codex.build_hook_response(HookDecision.allow(AiHookEventType.MCP_EXECUTION)) + assert allow == {'hookSpecificOutput': {'hookEventName': 'PreToolUse', 'permissionDecision': 'allow'}} + + deny = codex.build_hook_response(HookDecision.deny(AiHookEventType.MCP_EXECUTION, 'secret in args!')) + assert deny == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': 'secret in args!', + } + } + + +def test_build_mcp_execution_ask() -> None: + ask = Codex().build_hook_response(HookDecision.ask(AiHookEventType.MCP_EXECUTION, 'maybe?')) + assert ask == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'ask', + 'permissionDecisionReason': 'maybe?', + } + } + + +# --- settings paths ---------------------------------------------------------- + + +def test_settings_path_user_scope() -> None: + path = Codex().settings_path('user') + assert path.name == 'hooks.json' + assert path.parent.name == '.codex' + + +def test_settings_path_repo_scope(fs: FakeFilesystem) -> None: + repo = Path('/my-repo') + fs.create_dir(repo) + path = Codex().settings_path('repo', repo) + assert path == repo / '.codex' / 'hooks.json' + + +def test_settings_path_honors_codex_home_env(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + override = '/custom/codex/home' + fs.create_dir(override) + monkeypatch.setenv('CODEX_HOME', override) + assert _codex_home() == Path(override) + assert Codex().settings_path('user') == Path(override) / 'hooks.json' + + +# --- hooks config rendering -------------------------------------------------- + + +def test_render_hooks_session_start_matcher_includes_clear() -> None: + """SessionStart must fire on /clear too (conversation_id rotates).""" + rendered = Codex().render_hooks_config() + assert rendered['hooks']['SessionStart'][0]['matcher'] == 'startup|clear' + assert '--ide codex' in rendered['hooks']['SessionStart'][0]['hooks'][0]['command'] + + +def test_render_hooks_never_emits_async_toml_flags() -> None: + """Codex's TOML `async: true` / `timeout` flags are unimplemented; we must not emit them.""" + for mode in (False, True): + rendered = Codex().render_hooks_config(async_mode=mode) + for entry in rendered['hooks']['PreToolUse']: + for hook in entry['hooks']: + assert 'async' not in hook + assert 'timeout' not in hook + + +def test_render_hooks_async_backgrounds_scan_hooks() -> None: + """In async mode, UserPromptSubmit + PreToolUse scan hooks shell-background.""" + rendered = Codex().render_hooks_config(async_mode=True) + prompt_cmd = rendered['hooks']['UserPromptSubmit'][0]['hooks'][0]['command'] + pretool_cmd = rendered['hooks']['PreToolUse'][0]['hooks'][0]['command'] + assert prompt_cmd.endswith(' &') + assert pretool_cmd.endswith(' &') + + +def test_render_hooks_session_start_always_synchronous() -> None: + """SessionStart registers the conversation context — never backgrounded.""" + for mode in (False, True): + rendered = Codex().render_hooks_config(async_mode=mode) + session_cmd = rendered['hooks']['SessionStart'][0]['hooks'][0]['command'] + assert '&' not in session_cmd + + +def test_render_hooks_pretooluse_matchers_are_mcp_only() -> None: + matchers = [e['matcher'] for e in Codex().render_hooks_config()['hooks']['PreToolUse']] + assert matchers == ['mcp__.*'] + + +# --- post_install: TOML feature flag ---------------------------------------- + + +def test_post_install_creates_config_toml(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + + success, message = Codex().post_install('user') + assert success is True + config_path = Path(home) / 'config.toml' + assert config_path.exists() + assert 'config.toml' in message + + with config_path.open('rb') as f: + config = tomllib.load(f) + assert config['features']['hooks'] is True + + +def test_post_install_preserves_existing_keys(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + config_path = Path(home) / 'config.toml' + # Pre-existing settings the user cares about + config_path.write_text('model = "gpt-5-codex"\n\n[features]\nother = true\n') + + success, _ = Codex().post_install('user') + assert success is True + + with config_path.open('rb') as f: + config = tomllib.load(f) + assert config['model'] == 'gpt-5-codex' + assert config['features']['other'] is True + assert config['features']['hooks'] is True + + +def test_post_install_repo_scope_writes_to_repo_dir(fs: FakeFilesystem) -> None: + repo = Path('/my-repo') + fs.create_dir(repo) + success, _ = Codex().post_install('repo', repo) + assert success is True + assert (repo / '.codex' / 'config.toml').exists() + + +def test_enable_codex_hooks_feature_fails_on_corrupt_toml(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + (Path(home) / 'config.toml').write_text('this is = not [ valid] toml = ') + + success, message = _enable_codex_hooks_feature('user') + assert success is False + assert 'Failed to parse' in message + + +# --- TOML config loading ----------------------------------------------------- + + +def test_load_codex_config_valid(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + (Path(home) / 'config.toml').write_text('model = "gpt-5-codex"\n[mcp_servers.linear]\ncommand = "linear-mcp"\n') + + config = _load_codex_config() + assert config is not None + assert config['model'] == 'gpt-5-codex' + assert config['mcp_servers']['linear']['command'] == 'linear-mcp' + + +def test_load_codex_config_missing_file(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + assert _load_codex_config() is None + + +def test_load_codex_config_invalid_toml(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + (Path(home) / 'config.toml').write_text('this is = not [ valid] toml = ') + assert _load_codex_config() is None + + +# --- JWT email extraction ---------------------------------------------------- + + +def _make_jwt(claims: dict) -> str: + """Build a JWT-shaped token with the given claims (signature ignored).""" + header = base64.urlsafe_b64encode(b'{"alg":"RS256"}').rstrip(b'=').decode() + payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).rstrip(b'=').decode() + return f'{header}.{payload}.signature-not-verified' + + +def test_email_from_auth_returns_email(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + token = _make_jwt({'email': 'codex-user@example.com'}) + (Path(home) / 'auth.json').write_text(json.dumps({'tokens': {'id_token': token}})) + + assert _email_from_auth() == 'codex-user@example.com' + + +def test_email_from_auth_missing_file(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + assert _email_from_auth() is None + + +def test_email_from_auth_no_id_token(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + (Path(home) / 'auth.json').write_text(json.dumps({'tokens': {}})) + assert _email_from_auth() is None + + +def test_email_from_auth_malformed_token(fs: FakeFilesystem, monkeypatch: pytest.MonkeyPatch) -> None: + home = '/codex-home' + fs.create_dir(home) + monkeypatch.setenv('CODEX_HOME', home) + (Path(home) / 'auth.json').write_text(json.dumps({'tokens': {'id_token': 'not.a.jwt-with-bad-payload!!'}})) + assert _email_from_auth() is None + + +# --- session context -------------------------------------------------------- + + +def test_session_context_reads_mcp_servers() -> None: + mcp = {'linear': {'command': 'linear-mcp'}, 'github': {'command': 'gh-mcp'}} + with patch( + 'cycode.cli.apps.ai_guardrails.ides.codex._load_codex_config', + return_value={'mcp_servers': mcp}, + ): + servers, plugins = Codex().get_session_context() + assert servers == mcp + assert plugins == {} + + +def test_session_context_no_config() -> None: + with patch('cycode.cli.apps.ai_guardrails.ides.codex._load_codex_config', return_value=None): + servers, plugins = Codex().get_session_context() + assert servers == {} + assert plugins == {} diff --git a/tests/cli/commands/ai_guardrails/ides/test_contract.py b/tests/cli/commands/ai_guardrails/ides/test_contract.py index 9714dbfa..7d7a5427 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_contract.py +++ b/tests/cli/commands/ai_guardrails/ides/test_contract.py @@ -61,7 +61,7 @@ def test_render_hooks_config_has_hooks_key(ide: IDE) -> None: def test_render_hooks_config_async_changes_output(ide: IDE) -> None: - """async_mode must influence the rendered output (e.g. & suffix, async flag).""" + """async_mode must influence the rendered output.""" assert ide.render_hooks_config(async_mode=False) != ide.render_hooks_config(async_mode=True) diff --git a/tests/cli/commands/ai_guardrails/test_hooks_manager.py b/tests/cli/commands/ai_guardrails/test_hooks_manager.py index 4ff8d575..f0a0248c 100644 --- a/tests/cli/commands/ai_guardrails/test_hooks_manager.py +++ b/tests/cli/commands/ai_guardrails/test_hooks_manager.py @@ -1,17 +1,27 @@ """Tests for AI guardrails hooks manager and per-IDE hooks rendering.""" from pathlib import Path +from typing import TYPE_CHECKING import yaml from pyfakefs.fake_filesystem import FakeFilesystem +if TYPE_CHECKING: + import pytest + from cycode.cli.apps.ai_guardrails.consts import ( CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND, PolicyMode, ) -from cycode.cli.apps.ai_guardrails.hooks_manager import create_policy_file, is_cycode_hook_entry +from cycode.cli.apps.ai_guardrails.hooks_manager import ( + create_policy_file, + install_hooks, + is_cycode_hook_entry, + uninstall_hooks, +) from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode +from cycode.cli.apps.ai_guardrails.ides.codex import Codex from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor @@ -101,11 +111,11 @@ def test_claude_code_render_hooks_async() -> None: def test_claude_code_render_hooks_session_start() -> None: - """Claude Code SessionStart carries the --ide flag explicitly.""" + """Claude Code SessionStart fires on startup and /clear.""" config = ClaudeCode().render_hooks_config() - assert 'SessionStart' in config['hooks'] entries = config['hooks']['SessionStart'] assert len(entries) == 1 + assert entries[0]['matcher'] == 'startup|clear' assert CYCODE_SESSION_START_COMMAND in entries[0]['hooks'][0]['command'] assert '--ide claude-code' in entries[0]['hooks'][0]['command'] @@ -153,6 +163,101 @@ def test_create_policy_file_updates_existing(fs: FakeFilesystem) -> None: assert policy['custom_field'] == 'keep_me' +def test_install_preserves_user_hook_colocated_with_cycode( + fs: FakeFilesystem, monkeypatch: 'pytest.MonkeyPatch' +) -> None: + """install must not clobber a user-authored hook that shares + an entry with a Cycode hook. The filter is hook-level, not entry-level. + """ + import json + + repo = Path('/repo') + fs.create_dir(repo) + hooks_path = repo / '.codex' / 'hooks.json' + fs.create_file( + hooks_path, + contents=json.dumps( + { + 'version': 1, + 'hooks': { + 'SessionStart': [ + { + 'matcher': 'startup|clear', + 'hooks': [ + {'type': 'command', 'command': '/usr/local/bin/user-debug.sh SessionStart'}, + {'type': 'command', 'command': 'cycode ai-guardrails session-start --ide codex'}, + ], + } + ], + # Unrelated event with no Cycode hooks at all — must be untouched. + 'PostToolUse': [{'hooks': [{'type': 'command', 'command': '/usr/local/bin/user-postlog.sh'}]}], + }, + } + ), + ) + + # Codex's post_install touches ~/.codex/config.toml (user scope) — keep that off + # the filesystem under test by pinning CODEX_HOME inside the fake FS. + monkeypatch.setenv('CODEX_HOME', '/codex-home') + fs.create_dir('/codex-home') + + success, _ = install_hooks(Codex(), scope='repo', repo_path=repo) + assert success is True + + saved = json.loads(hooks_path.read_text()) + session_start = saved['hooks']['SessionStart'] + # The pre-existing entry should still exist with the user hook preserved, + # and a separate fresh Cycode entry should have been appended. + user_hook_cmd = '/usr/local/bin/user-debug.sh SessionStart' + remaining_user_hooks = [ + h for entry in session_start for h in entry.get('hooks', []) if h.get('command') == user_hook_cmd + ] + assert remaining_user_hooks, 'user hook was clobbered by install' + + # Unrelated event untouched. + assert saved['hooks']['PostToolUse'][0]['hooks'][0]['command'] == '/usr/local/bin/user-postlog.sh' + + +def test_uninstall_preserves_user_hook_colocated_with_cycode( + fs: FakeFilesystem, monkeypatch: 'pytest.MonkeyPatch' +) -> None: + """uninstall must strip only the Cycode hook from a mixed entry.""" + import json + + repo = Path('/repo') + fs.create_dir(repo) + hooks_path = repo / '.codex' / 'hooks.json' + fs.create_file( + hooks_path, + contents=json.dumps( + { + 'version': 1, + 'hooks': { + 'UserPromptSubmit': [ + { + 'hooks': [ + {'type': 'command', 'command': '/usr/local/bin/user-debug.sh UserPromptSubmit'}, + {'type': 'command', 'command': 'cycode ai-guardrails scan --ide codex'}, + ] + } + ] + }, + } + ), + ) + monkeypatch.setenv('CODEX_HOME', '/codex-home') + fs.create_dir('/codex-home') + + success, _ = uninstall_hooks(Codex(), scope='repo', repo_path=repo) + assert success is True + + saved = json.loads(hooks_path.read_text()) + hooks = saved['hooks']['UserPromptSubmit'][0]['hooks'] + commands = [h['command'] for h in hooks] + assert '/usr/local/bin/user-debug.sh UserPromptSubmit' in commands + assert not any('cycode ai-guardrails' in c for c in commands) + + def test_create_policy_file_repo_scope(fs: FakeFilesystem) -> None: """Create a policy file in repo scope.""" repo_path = Path('/my-repo') From 39f70a5077b7d1f7164df2b8ff4ace4d8bb896b4 Mon Sep 17 00:00:00 2001 From: aaron-butler-cy-int Date: Wed, 27 May 2026 11:03:35 -0400 Subject: [PATCH 298/347] CM-63882 - Added scanType validation (#452) Co-authored-by: omerr-cycode --- cycode/cli/apps/scan/scan_command.py | 22 +++++++++++++++++-- tests/cli/commands/scan/test_scan_command.py | 23 ++++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 9b2aa280..427f2d78 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -28,17 +28,32 @@ _SECRET_RICH_HELP_PANEL = 'Secret options' +def _single_value_callback(ctx: typer.Context, param: typer.CallbackParam, value: list) -> list: + if len(value) > 1: + values_str = ', '.join(str(v) for v in value) + param_hint = '/'.join(sorted(param.opts, key=len)) + err = typer.BadParameter( + f'Only one value can be specified per command. Got: {values_str}. Run a separate command for each value.', + ctx=ctx, + param_hint=param_hint, + ) + err.exit_code = 1 + raise err + return value + + def scan_command( ctx: typer.Context, scan_type: Annotated[ - ScanTypeOption, + list[ScanTypeOption], typer.Option( '--scan-type', '-t', help='Specify the type of scan you wish to execute.', case_sensitive=False, + callback=_single_value_callback, ), - ] = ScanTypeOption.SECRET, + ] = (ScanTypeOption.SECRET,), soft_fail: Annotated[ bool, typer.Option('--soft-fail', help='Run the scan without failing; always return a non-error status code.') ] = False, @@ -137,6 +152,9 @@ def scan_command( param_hint='--export-file', ) + # _single_value_callback validated exactly one value was provided; unwrap from list + scan_type = scan_type[0] + ctx.obj['show_secret'] = show_secret ctx.obj['soft_fail'] = soft_fail ctx.obj['stop_on_error'] = stop_on_error diff --git a/tests/cli/commands/scan/test_scan_command.py b/tests/cli/commands/scan/test_scan_command.py index de218da5..bb5f363d 100644 --- a/tests/cli/commands/scan/test_scan_command.py +++ b/tests/cli/commands/scan/test_scan_command.py @@ -1,11 +1,19 @@ +import re + import click import pytest import typer +from typer.testing import CliRunner +from cycode.cli.app import app from cycode.cli.apps.scan.scan_command import scan_command_result_callback from cycode.cli.consts import ISSUE_DETECTED_STATUS_CODE, NO_ISSUES_STATUS_CODE, SCAN_ERROR_STATUS_CODE +def _strip_ansi(text: str) -> str: + return re.sub(r'\x1b\[[0-9;]*[mGKHF]', '', text) + + def _make_ctx(**obj_overrides: object) -> click.Context: obj = { 'soft_fail': False, @@ -25,6 +33,21 @@ def _invoke_result_callback(ctx: click.Context) -> int: return exc_info.value.exit_code +class TestScanCommand: + def test_multiple_scan_types_rejected(self) -> None: + result = CliRunner().invoke(app, ['scan', '-t', 'iac', '-t', 'sast', 'path', '.']) + assert result.exit_code == 1 + output = _strip_ansi(result.output) + assert '-t/--scan-type' in output + assert 'iac' in output + assert 'sast' in output + + def test_single_scan_type_accepted(self) -> None: + result = CliRunner().invoke(app, ['scan', '-t', 'iac', '--help']) + assert result.exit_code == 0 + assert 'Error' not in result.output + + class TestScanCommandResultCallback: def test_no_issues_no_errors_exits_zero(self) -> None: assert _invoke_result_callback(_make_ctx()) == NO_ISSUES_STATUS_CODE From af3127cb6be77a3d6d8af36dc05ec291e035fbb3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 16:31:19 +0300 Subject: [PATCH 299/347] Bump svenstaro/upload-release-action from 2.11.4 to 2.11.5 (#428) 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 a843dcf5..94274f99 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -313,7 +313,7 @@ jobs: - name: Upload files to release if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish }} - uses: svenstaro/upload-release-action@b98a3b12e86552593f3e4e577ca8a62aa2f3f22b # v2 + uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # v2 with: file: dist/* tag: ${{ env.LATEST_TAG }} From 8b798456ec7e1699dbb64c4a4061d8c443934b4c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:38:44 +0300 Subject: [PATCH 300/347] Bump docker/build-push-action from 7.1.0 to 7.2.0 (#467) 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 f19b4e2d..412e38b9 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -76,7 +76,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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . platforms: linux/amd64,linux/arm64 @@ -86,7 +86,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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 with: context: . platforms: linux/amd64,linux/arm64 From 61e5289ea5e2284057921653be752da215c42592 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 07:47:52 +0300 Subject: [PATCH 301/347] Bump cycodelabs/cimon-action from 0.10.1 to 1.0.1 (#466) 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/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 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index 94274f99..eef17ad3 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -38,7 +38,7 @@ jobs: steps: - name: Run Cimon if: matrix.os == 'ubuntu-22.04' - uses: cycodelabs/cimon-action@3ca67e875f34772093aa3bf3c185a711720bf5d9 # v0.10.1 + uses: cycodelabs/cimon-action@a0870cc3d9e3bf3cedd28bdb67bf3fd3281e5941 # v1.0.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index fd183691..171238cd 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@3ca67e875f34772093aa3bf3c185a711720bf5d9 # v0.10.1 + uses: cycodelabs/cimon-action@a0870cc3d9e3bf3cedd28bdb67bf3fd3281e5941 # v1.0.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index acee4571..9f9d2a73 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@3ca67e875f34772093aa3bf3c185a711720bf5d9 # v0.10.1 + uses: cycodelabs/cimon-action@a0870cc3d9e3bf3cedd28bdb67bf3fd3281e5941 # v1.0.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index fcc7a882..2f0e1253 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Run Cimon - uses: cycodelabs/cimon-action@3ca67e875f34772093aa3bf3c185a711720bf5d9 # v0.10.1 + uses: cycodelabs/cimon-action@a0870cc3d9e3bf3cedd28bdb67bf3fd3281e5941 # v1.0.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index bd275986..517f10d1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,7 +11,7 @@ jobs: steps: - name: Run Cimon - uses: cycodelabs/cimon-action@3ca67e875f34772093aa3bf3c185a711720bf5d9 # v0.10.1 + uses: cycodelabs/cimon-action@a0870cc3d9e3bf3cedd28bdb67bf3fd3281e5941 # v1.0.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index b6fee12e..f2cf8d9e 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Run Cimon if: matrix.os == 'ubuntu-latest' - uses: cycodelabs/cimon-action@3ca67e875f34772093aa3bf3c185a711720bf5d9 # v0.10.1 + uses: cycodelabs/cimon-action@a0870cc3d9e3bf3cedd28bdb67bf3fd3281e5941 # v1.0.1 with: client-id: ${{ secrets.CIMON_CLIENT_ID }} secret: ${{ secrets.CIMON_SECRET }} From 41285c086f755fdcd6ec769065f885ef28339c70 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 08:03:17 +0300 Subject: [PATCH 302/347] Bump pyinstaller from 6.19.0 to 6.20.0 (#443) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 41 ++++++++++++++++++++--------------------- pyproject.toml | 2 +- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/poetry.lock b/poetry.lock index eab3b4da..51da19c7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.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" @@ -339,7 +339,6 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -markers = {test = "sys_platform == \"win32\""} [[package]] name = "coverage" @@ -743,7 +742,7 @@ files = [ [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.3.6" +jsonschema-specifications = ">=2023.03.6" referencing = ">=0.28.4" rpds-py = ">=0.7.1" @@ -1180,25 +1179,25 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyinstaller" -version = "6.19.0" +version = "6.20.0" description = "PyInstaller bundles a Python application and all its dependencies into a single package." optional = false python-versions = "<3.15,>=3.8" groups = ["executable"] 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"}, + {file = "pyinstaller-6.20.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:bf3be4e1284ee78ddccba5e29f99443a12a7b4673168288ffc4c9d38c6f7b90e"}, + {file = "pyinstaller-6.20.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:72ae9c1fdea134afa791f58bdc9a1934d5c7609753c111e0026bfc272b32b712"}, + {file = "pyinstaller-6.20.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1031bcc307f3fbeffd4e162723e64d46dbf591c82dd0997413afb2a07328b941"}, + {file = "pyinstaller-6.20.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:8df3b3f347659fa2562d8d193a98ad4600133b8b8d07c268df89e4154376750e"}, + {file = "pyinstaller-6.20.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:b0d3cc9dd8120d448459bd3880a12e2f9774c51443af49047801446377999a59"}, + {file = "pyinstaller-6.20.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:03696bb6350177c6bc23bcaf78e71a33c4a89b6754dd90d1be2f318e978c918b"}, + {file = "pyinstaller-6.20.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:6357f1699f6af84f37e7367f031d4f68abdba65543b83990c9e8f5a4cebed0b7"}, + {file = "pyinstaller-6.20.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0ab39c690abad26ba148e8f664f0478acc82a733997f4f22e757774832802da9"}, + {file = "pyinstaller-6.20.0-py3-none-win32.whl", hash = "sha256:9a7637e8e44b4387b13667fdcaac86ab6b29c446c16d34d8401539b81838759c"}, + {file = "pyinstaller-6.20.0-py3-none-win_amd64.whl", hash = "sha256:d588844e890ee80c4365867f98146636e1849bbca8e4284bbf0c809aff0f161a"}, + {file = "pyinstaller-6.20.0-py3-none-win_arm64.whl", hash = "sha256:bd53282c0a73e5c95573e1ddc8e5d564d4932bec91efbaed4dc5fdff9c2ae7f2"}, + {file = "pyinstaller-6.20.0.tar.gz", hash = "sha256:95c5c7e03d5d61e9dfb8ef259c699cf492bb1041beb6dbe83696608cec07347a"}, ] [package.dependencies] @@ -1207,7 +1206,7 @@ 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 = ">=2026.0" +pyinstaller-hooks-contrib = ">=2026.4" pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""} setuptools = ">=42.0.0" @@ -1217,15 +1216,15 @@ hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] [[package]] name = "pyinstaller-hooks-contrib" -version = "2026.0" +version = "2026.5" description = "Community maintained hooks for PyInstaller" optional = false python-versions = ">=3.8" groups = ["executable"] markers = "python_version < \"3.15\"" files = [ - {file = "pyinstaller_hooks_contrib-2026.0-py3-none-any.whl", hash = "sha256:0590db8edeba3e6c30c8474937021f5cd39c0602b4d10f74a064c73911efaca5"}, - {file = "pyinstaller_hooks_contrib-2026.0.tar.gz", hash = "sha256:0120893de491a000845470ca9c0b39284731ac6bace26f6849dea9627aaed48e"}, + {file = "pyinstaller_hooks_contrib-2026.5-py3-none-any.whl", hash = "sha256:ea1535783fbdac4626351709e83f3ea80b681d3a4745763ebb407b5e27342eb9"}, + {file = "pyinstaller_hooks_contrib-2026.5.tar.gz", hash = "sha256:f066dfca8f7c45ff6336c9cf9fe25b4e48bfeb322a1aa24faaedfb8a8d1b0b08"}, ] [package.dependencies] @@ -2056,4 +2055,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "ac37763cb9b582d1997853c1347edfdb05566fa225adf304685ecce66989fc67" +content-hash = "36b21102c474b6c7efbf1e70d213c5c6138fc12e28f6c4bde3d253d4f3c73299" diff --git a/pyproject.toml b/pyproject.toml index 1b43757b..fd20bccd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -61,7 +61,7 @@ responses = ">=0.23.1,<0.27.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"} +pyinstaller = {version=">=6.20.0,<7.0.0", python=">=3.9,<3.15"} dunamai = ">=1.26.1,<1.27.0" [tool.poetry.group.dev.dependencies] From c33b781fc4f929e6e703e5ebbd2f0a1307871f8a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 08:16:38 +0300 Subject: [PATCH 303/347] Bump actions/upload-artifact from 4.6.2 to 7.0.1 (#449) 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 eef17ad3..d3ebb83e 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -265,7 +265,7 @@ jobs: run: echo "ARTIFACT_NAME=$(./process_executable_file.py dist/cycode-cli)" >> $GITHUB_ENV - name: Upload files as artifact - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ env.ARTIFACT_NAME }} path: dist From 22c1f20f222b4cecb00a54a71e49be4a65fc7b3e Mon Sep 17 00:00:00 2001 From: omerr-cycode Date: Wed, 3 Jun 2026 10:34:24 +0300 Subject: [PATCH 304/347] CM-65133 upgrade ruff to 0.15.14 (#462) --- cycode/cli/apps/mcp/mcp_command.py | 7 +- poetry.lock | 1215 ++++++++--------- pyproject.toml | 3 +- .../test_commit_range_documents.py | 32 +- 4 files changed, 613 insertions(+), 644 deletions(-) diff --git a/cycode/cli/apps/mcp/mcp_command.py b/cycode/cli/apps/mcp/mcp_command.py index adfc0a3f..517f514f 100644 --- a/cycode/cli/apps/mcp/mcp_command.py +++ b/cycode/cli/apps/mcp/mcp_command.py @@ -8,6 +8,7 @@ import uuid from typing import Annotated, Any, Optional +import anyio import typer from pathvalidate import sanitize_filepath from pydantic import Field @@ -65,6 +66,7 @@ def _get_current_executable() -> str: return 'cycode' +# ruff: disable[ASYNC109] 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. @@ -109,6 +111,9 @@ async def _run_cycode_command(*args: str, timeout: int = _DEFAULT_RUN_COMMAND_TI return {'error': f'Failed to run command: {e!s}'} +# ruff: enable[ASYNC109] + + def _sanitize_file_path(file_path: str) -> str: """Sanitize file path to prevent path traversal and other security issues. @@ -238,7 +243,7 @@ async def _cycode_scan_tool( try: if paths: - missing = [p for p in paths if not os.path.exists(p)] + missing = [p for p in paths if not await anyio.Path(p).exists()] if missing: return json.dumps({'error': f'Paths not found on disk: {missing}'}, indent=2) diff --git a/poetry.lock b/poetry.lock index 51da19c7..0b87b802 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,16 +1,16 @@ -# 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.4.1 and should not be changed by hand. [[package]] name = "altgraph" -version = "0.17.4" +version = "0.17.5" description = "Python graph (network) package" optional = false python-versions = "*" groups = ["executable"] 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"}, + {file = "altgraph-0.17.5-py2.py3-none-any.whl", hash = "sha256:f3a22400bce1b0c701683820ac4f3b159cd301acab067c51c653e06961600597"}, + {file = "altgraph-0.17.5.tar.gz", hash = "sha256:c87b395dd12fabde9c99573a9749d67da8d29ef9de0125c7f536699b4a9bc9e7"}, ] [[package]] @@ -27,25 +27,23 @@ files = [ [[package]] name = "anyio" -version = "4.11.0" +version = "4.12.1" 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\"" files = [ - {file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"}, - {file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"}, + {file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"}, + {file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"}, ] [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] -trio = ["trio (>=0.31.0)"] +trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""] [[package]] name = "arrow" @@ -69,27 +67,27 @@ test = ["dateparser (==1.*)", "pre-commit", "pytest", "pytest-cov", "pytest-mock [[package]] name = "attrs" -version = "25.4.0" +version = "26.1.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.9" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373"}, - {file = "attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11"}, + {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, + {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, ] [[package]] name = "certifi" -version = "2025.10.5" +version = "2026.5.20" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["main", "test"] files = [ - {file = "certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de"}, - {file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"}, + {file = "certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897"}, + {file = "certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d"}, ] [[package]] @@ -192,125 +190,141 @@ pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} [[package]] name = "charset-normalizer" -version = "3.4.4" +version = "3.4.7" 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.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"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"}, + {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"}, + {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"}, + {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"}, + {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"}, + {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"}, + {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"}, + {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"}, + {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"}, + {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"}, + {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"}, ] [[package]] @@ -339,6 +353,7 @@ files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {test = "sys_platform == \"win32\""} [[package]] name = "coverage" @@ -459,77 +474,70 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" -version = "46.0.5" +version = "48.0.0" 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" +python-versions = "!=3.9.0,!=3.9.1,>=3.9" 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"}, + {file = "cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c"}, + {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5"}, + {file = "cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321"}, + {file = "cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74"}, + {file = "cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4"}, + {file = "cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7"}, + {file = "cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336"}, + {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057"}, + {file = "cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae"}, + {file = "cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c"}, + {file = "cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f"}, + {file = "cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12"}, + {file = "cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a"}, + {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239"}, + {file = "cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c"}, + {file = "cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4"}, + {file = "cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd"}, + {file = "cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8"}, + {file = "cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855"}, + {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b"}, + {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13"}, + {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb"}, + {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355"}, + {file = "cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a"}, + {file = "cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920"}, ] [package.dependencies] -cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""} +cffi = {version = ">=2.0.0", markers = "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" @@ -548,16 +556,16 @@ packaging = ">=20.9" [[package]] name = "exceptiongroup" -version = "1.3.0" +version = "1.3.1" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" groups = ["main", "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"}, + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, ] -markers = {main = "python_version == \"3.10\"", test = "python_version < \"3.11\""} [package.dependencies] typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} @@ -677,30 +685,30 @@ files = [ [[package]] name = "idna" -version = "3.11" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["main", "test"] files = [ - {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, - {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] [[package]] name = "importlib-metadata" -version = "8.7.0" +version = "8.7.1" description = "Read metadata from Python packages" optional = false python-versions = ">=3.9" groups = ["executable"] 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"}, + {file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"}, + {file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"}, ] [package.dependencies] @@ -710,10 +718,10 @@ zipp = ">=3.20" 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)"] +enabler = ["pytest-enabler (>=3.4)"] 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)"] -type = ["pytest-mypy"] +test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"] [[package]] name = "iniconfig" @@ -729,22 +737,22 @@ files = [ [[package]] name = "jsonschema" -version = "4.25.1" +version = "4.26.0" description = "An implementation of JSON Schema validation for Python" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63"}, - {file = "jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85"}, + {file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"}, + {file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"}, ] [package.dependencies] attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.03.6" +jsonschema-specifications = ">=2023.3.6" referencing = ">=0.28.4" -rpds-py = ">=0.7.1" +rpds-py = ">=0.25.0" [package.extras] format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] @@ -768,15 +776,15 @@ referencing = ">=0.31.0" [[package]] name = "macholib" -version = "1.16.3" +version = "1.16.4" description = "Mach-O header analysis and editing" optional = false python-versions = "*" groups = ["executable"] 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"}, + {file = "macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea"}, + {file = "macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362"}, ] [package.dependencies] @@ -829,20 +837,20 @@ tests = ["pytest", "simplejson"] [[package]] name = "mcp" -version = "1.26.0" +version = "1.27.2" description = "Model Context Protocol SDK" optional = false python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca"}, - {file = "mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66"}, + {file = "mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5"}, + {file = "mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef"}, ] [package.dependencies] anyio = ">=4.5" -httpx = ">=0.27.1" +httpx = ">=0.27.1,<1.0.0" httpx-sse = ">=0.4" jsonschema = ">=4.20.0" pydantic = ">=2.11.0,<3.0.0" @@ -892,14 +900,14 @@ test = ["pytest (<5.4)", "pytest-cov"] [[package]] name = "packaging" -version = "25.0" +version = "26.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" groups = ["main", "executable", "test"] files = [ - {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, - {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, + {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, + {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, ] [[package]] @@ -975,19 +983,19 @@ files = [ [[package]] name = "pydantic" -version = "2.12.3" +version = "2.13.4" description = "Data validation using Python type hints" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf"}, - {file = "pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74"}, + {file = "pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba"}, + {file = "pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6"}, ] [package.dependencies] annotated-types = ">=0.6.0" -pydantic-core = "2.41.4" +pydantic-core = "2.46.4" typing-extensions = ">=4.14.1" typing-inspection = ">=0.4.2" @@ -997,129 +1005,132 @@ timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows [[package]] name = "pydantic-core" -version = "2.41.4" +version = "2.46.4" description = "Core functionality for Pydantic validation and serialization" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {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"}, + {file = "pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4"}, + {file = "pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01"}, + {file = "pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f"}, + {file = "pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39"}, + {file = "pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d"}, + {file = "pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf"}, + {file = "pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594"}, + {file = "pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398"}, + {file = "pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3"}, + {file = "pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33"}, + {file = "pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d"}, + {file = "pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2"}, + {file = "pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987"}, + {file = "pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b"}, + {file = "pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89"}, + {file = "pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a"}, + {file = "pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008"}, + {file = "pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262"}, + {file = "pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be"}, + {file = "pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292"}, + {file = "pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d"}, + {file = "pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb"}, + {file = "pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c"}, + {file = "pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e"}, + {file = "pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac"}, + {file = "pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5"}, + {file = "pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596"}, + {file = "pydantic_core-2.46.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae"}, + {file = "pydantic_core-2.46.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066"}, + {file = "pydantic_core-2.46.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29"}, + {file = "pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9"}, + {file = "pydantic_core-2.46.4-cp39-cp39-win32.whl", hash = "sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1"}, + {file = "pydantic_core-2.46.4-cp39-cp39-win_amd64.whl", hash = "sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b"}, + {file = "pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526"}, + {file = "pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc"}, + {file = "pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983"}, + {file = "pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1"}, ] [package.dependencies] @@ -1127,15 +1138,15 @@ typing-extensions = ">=4.14.1" [[package]] name = "pydantic-settings" -version = "2.11.0" +version = "2.14.1" description = "Settings management using Pydantic" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c"}, - {file = "pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180"}, + {file = "pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de"}, + {file = "pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa"}, ] [package.dependencies] @@ -1144,7 +1155,7 @@ python-dotenv = ">=0.21.0" typing-inspection = ">=0.4.0" [package.extras] -aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"] +aws-secrets-manager = ["boto3 (>=1.35.0)", "types-boto3[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)"] @@ -1234,24 +1245,22 @@ setuptools = ">=42.0.0" [[package]] name = "pyjwt" -version = "2.12.0" +version = "2.13.0" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "pyjwt-2.12.0-py3-none-any.whl", hash = "sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e"}, - {file = "pyjwt-2.12.0.tar.gz", hash = "sha256:2f62390b667cd8257de560b850bb5a883102a388829274147f1d724453f8fb02"}, + {file = "pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728"}, + {file = "pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423"}, ] [package.dependencies] cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} +typing_extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} [package.extras] crypto = ["cryptography (>=3.4.0)"] -dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"] -docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] -tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"] [[package]] name = "pytest" @@ -1312,15 +1321,15 @@ six = ">=1.5" [[package]] name = "python-dotenv" -version = "1.1.1" +version = "1.2.2" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"}, - {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"}, + {file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"}, + {file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"}, ] [package.extras] @@ -1328,15 +1337,15 @@ cli = ["click (>=5.0)"] [[package]] name = "python-multipart" -version = "0.0.22" +version = "0.0.30" description = "A streaming multipart parser for Python" optional = false python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155"}, - {file = "python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58"}, + {file = "python_multipart-0.0.30-py3-none-any.whl", hash = "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b"}, + {file = "python_multipart-0.0.30.tar.gz", hash = "sha256:0edfe0475c1f46ddd3ff7785a626f6118af32bdcf359bb21260367313bb32118"}, ] [[package]] @@ -1508,14 +1517,14 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "responses" -version = "0.26.0" +version = "0.26.1" description = "A utility library for mocking out the `requests` Python library." optional = false python-versions = ">=3.8" groups = ["test"] files = [ - {file = "responses-0.26.0-py3-none-any.whl", hash = "sha256:03ec4409088cd5c66b71ecbbbd27fe2c58ddfad801c66203457b3e6a04868c37"}, - {file = "responses-0.26.0.tar.gz", hash = "sha256:c7f6923e6343ef3682816ba421c006626777893cb0d5e1434f674b649bac9eb4"}, + {file = "responses-0.26.1-py3-none-any.whl", hash = "sha256:8aacc4586eb08fb2208ef64a9eb4258d9b0c6e6f4260845f2f018ab847495345"}, + {file = "responses-0.26.1.tar.gz", hash = "sha256:2eb3218553cc8f79b57d257bac23af5e1bf381f5b9390b1767816f0843e01dc2"}, ] [package.dependencies] @@ -1548,219 +1557,179 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] [[package]] name = "rpds-py" -version = "0.27.1" +version = "0.30.0" description = "Python bindings to Rust's persistent data structures (rpds)" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {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"}, + {file = "rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288"}, + {file = "rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221"}, + {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7"}, + {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139"}, + {file = "rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464"}, + {file = "rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425"}, + {file = "rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d"}, + {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed"}, + {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85"}, + {file = "rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825"}, + {file = "rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad"}, + {file = "rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6"}, + {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e"}, + {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394"}, + {file = "rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b"}, + {file = "rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2"}, + {file = "rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e"}, + {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31"}, + {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95"}, + {file = "rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15"}, + {file = "rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a"}, + {file = "rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9"}, + {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08"}, + {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6"}, + {file = "rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0"}, + {file = "rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07"}, + {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f"}, + {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53"}, + {file = "rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950"}, + {file = "rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb"}, + {file = "rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8"}, + {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856"}, + {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0"}, + {file = "rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4"}, + {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e"}, + {file = "rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84"}, ] [[package]] name = "ruff" -version = "0.11.7" +version = "0.15.15" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {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"}, + {file = "ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b"}, + {file = "ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e"}, + {file = "ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530"}, + {file = "ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4"}, + {file = "ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f"}, + {file = "ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622"}, + {file = "ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45"}, + {file = "ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627"}, + {file = "ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4"}, + {file = "ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c"}, + {file = "ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd"}, + {file = "ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f"}, + {file = "ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb"}, + {file = "ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a"}, + {file = "ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9"}, + {file = "ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4"}, + {file = "ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7"}, + {file = "ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6"}, ] [[package]] name = "setuptools" -version = "80.9.0" -description = "Easily download, build, install, upgrade, and uninstall Python packages" +version = "82.0.1" +description = "Most extensible Python build backend with support for C/C++ extension modules" optional = false python-versions = ">=3.9" groups = ["executable"] 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"}, + {file = "setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb"}, + {file = "setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9"}, ] [package.extras] -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)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.13.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)", "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.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"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.18.*)", "pytest-mypy"] [[package]] name = "shellingham" @@ -1788,62 +1757,51 @@ files = [ [[package]] name = "smmap" -version = "5.0.2" +version = "5.0.3" 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.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e"}, - {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"}, + {file = "smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f"}, + {file = "smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c"}, ] [[package]] name = "sse-starlette" -version = "3.0.2" +version = "3.4.4" description = "SSE plugin for Starlette" optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "sse_starlette-3.0.2-py3-none-any.whl", hash = "sha256:16b7cbfddbcd4eaca11f7b586f3b8a080f1afe952c15813455b162edea619e5a"}, - {file = "sse_starlette-3.0.2.tar.gz", hash = "sha256:ccd60b5765ebb3584d0de2d7a6e4f745672581de4f5005ab31c3a25d10b52b3a"}, + {file = "sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973"}, + {file = "sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0"}, ] [package.dependencies] anyio = ">=4.7.0" +starlette = ">=0.49.1" [package.extras] daphne = ["daphne (>=4.2.0)"] -examples = ["aiosqlite (>=0.21.0)", "fastapi (>=0.115.12)", "sqlalchemy[asyncio] (>=2.0.41)", "starlette (>=0.41.3)", "uvicorn (>=0.34.0)"] +examples = ["fastapi (>=0.115.12)", "pydantic (>=2)", "uvicorn (>=0.34.0)"] +examples-db = ["aiosqlite (>=0.21.0)", "sqlalchemy[asyncio] (>=2.0.41)"] granian = ["granian (>=2.3.1)"] uvicorn = ["uvicorn (>=0.34.0)"] [[package]] name = "starlette" -version = "0.49.1" +version = "1.2.1" description = "The little ASGI library that shines." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" 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"}, + {file = "starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89"}, + {file = "starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6"}, ] [package.dependencies] @@ -1871,55 +1829,60 @@ test = ["pytest", "tornado (>=4.5)", "typeguard"] [[package]] name = "tomli" -version = "2.3.0" +version = "2.4.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" groups = ["main", "test"] markers = "python_version < \"3.11\"" files = [ - {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"}, + {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"}, + {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"}, + {file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"}, + {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"}, + {file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"}, + {file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"}, + {file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"}, + {file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"}, + {file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"}, + {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"}, + {file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"}, + {file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"}, + {file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"}, + {file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"}, + {file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"}, + {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"}, + {file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"}, + {file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"}, + {file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"}, + {file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"}, + {file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"}, + {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"}, + {file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"}, + {file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"}, + {file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"}, + {file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"}, + {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"}, + {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"}, + {file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"}, + {file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"}, + {file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"}, + {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"}, + {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"}, ] [[package]] @@ -1982,14 +1945,14 @@ typing-extensions = ">=4.12.0" [[package]] name = "tzdata" -version = "2025.3" +version = "2026.2" 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"}, + {file = "tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7"}, + {file = "tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10"}, ] [[package]] @@ -2012,15 +1975,15 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "uvicorn" -version = "0.38.0" +version = "0.48.0" description = "The lightning-fast ASGI server." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\" and sys_platform != \"emscripten\"" files = [ - {file = "uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02"}, - {file = "uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d"}, + {file = "uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad"}, + {file = "uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37"}, ] [package.dependencies] @@ -2029,19 +1992,19 @@ 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)"] +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.20)", "websockets (>=10.4)"] [[package]] name = "zipp" -version = "3.23.0" +version = "3.23.1" 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.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, - {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, + {file = "zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc"}, + {file = "zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110"}, ] [package.extras] @@ -2055,4 +2018,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "36b21102c474b6c7efbf1e70d213c5c6138fc12e28f6c4bde3d253d4f3c73299" +content-hash = "a347b4566b5612c753acadaef737ad7ca594be3402e0118c94bb539ed2062b06" diff --git a/pyproject.toml b/pyproject.toml index fd20bccd..bfb34e90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ pydantic = ">=2.11.5,<3.0.0" pathvalidate = ">=3.3.1,<4.0.0" tomli-w = ">=1.0.0,<2.0.0" tomli = {version = ">=2.0.0,<3.0.0", python = "<3.11"} +anyio = ">=4.0.0, <4.13.0" [tool.poetry.group.test.dependencies] mock = ">=4.0.3,<4.1.0" @@ -65,7 +66,7 @@ pyinstaller = {version=">=6.20.0,<7.0.0", python=">=3.9,<3.15"} dunamai = ">=1.26.1,<1.27.0" [tool.poetry.group.dev.dependencies] -ruff = "0.11.7" +ruff = "0.15.15" [tool.pytest.ini_options] log_cli = true diff --git a/tests/cli/files_collector/test_commit_range_documents.py b/tests/cli/files_collector/test_commit_range_documents.py index 0b96a0e2..999e0e0c 100644 --- a/tests/cli/files_collector/test_commit_range_documents.py +++ b/tests/cli/files_collector/test_commit_range_documents.py @@ -67,7 +67,7 @@ def test_returns_head_when_repository_has_commits(self) -> None: 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): + 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 @@ -343,7 +343,7 @@ def test_diff_with_bare_repository(self) -> None: 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): + with temporary_git_repository() as (_temp_dir, repo): class MockDiff: def __init__(self) -> None: @@ -409,7 +409,7 @@ class TestGetDefaultBranchesForMergeBase: def test_environment_variable_override(self) -> None: """Test that the environment variable takes precedence.""" with ( - temporary_git_repository() as (temp_dir, repo), + 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) @@ -418,7 +418,7 @@ def test_environment_variable_override(self) -> None: def test_git_symbolic_ref_success(self) -> None: """Test getting default branch via git symbolic-ref.""" - with temporary_git_repository() as (temp_dir, repo): + 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' @@ -429,7 +429,7 @@ def test_git_symbolic_ref_success(self) -> None: 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): + 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' @@ -440,7 +440,7 @@ def test_git_symbolic_ref_with_master(self) -> None: 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): + 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') @@ -459,7 +459,7 @@ def test_git_remote_show_fallback(self) -> None: 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): + 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') @@ -474,7 +474,7 @@ def test_both_git_methods_fail_fallback_to_hardcoded(self) -> None: 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): + 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' @@ -486,7 +486,7 @@ def test_no_duplicates_in_branch_list(self) -> None: 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): + with temporary_git_repository() as (_temp_dir, _repo): mock_repo = Mock() mock_repo.git.symbolic_ref.return_value = 'refs/remotes/origin/develop' @@ -500,7 +500,7 @@ def test_env_var_plus_git_detection(self) -> None: def test_malformed_symbolic_ref_response(self) -> None: """Test handling of malformed symbolic-ref response.""" - with temporary_git_repository() as (temp_dir, repo): + 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' @@ -845,7 +845,7 @@ def _make_linear_history(self, repo: Repo, base_dir: str) -> tuple[str, str, str 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) + a, _b, c = self._make_linear_history(repo, temp_dir) parsed_from, parsed_to, separator = parse_commit_range(f'{a}..{c}', temp_dir) assert (parsed_from, parsed_to, separator) == (a, c, '..') @@ -853,7 +853,7 @@ def test_two_dot_linear_history(self) -> None: 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) + a, _b, c = self._make_linear_history(repo, temp_dir) parsed_from, parsed_to, separator = parse_commit_range(f'{a}...{c}', temp_dir) assert (parsed_from, parsed_to, separator) == (a, c, '...') @@ -861,7 +861,7 @@ def test_three_dot_linear_history(self) -> None: 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) + a, _b, c = self._make_linear_history(repo, temp_dir) parsed_from, parsed_to, separator = parse_commit_range(f'{a}..', temp_dir) assert (parsed_from, parsed_to, separator) == (a, c, '..') @@ -869,7 +869,7 @@ def test_open_right_linear_history(self) -> None: 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) + _a, _b, c = self._make_linear_history(repo, temp_dir) parsed_from, parsed_to, separator = parse_commit_range(f'..{c}', temp_dir) assert (parsed_from, parsed_to, separator) == (c, c, '..') @@ -877,7 +877,7 @@ def test_open_left_linear_history(self) -> None: 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) + a, _b, c = self._make_linear_history(repo, temp_dir) parsed_from, parsed_to, separator = parse_commit_range(a, temp_dir) assert (parsed_from, parsed_to, separator) == (a, c, '..') @@ -935,7 +935,7 @@ def test_parse_all_for_empty_remote_scenario_with_two_commits(self) -> None: 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): + 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 From e46504511ed26208417f6e1ddc099bc98159ced3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 10:56:47 +0300 Subject: [PATCH 305/347] Bump pypa/gh-action-pypi-publish from 1.13.0 to 1.14.0 (#468) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pre_release.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 171238cd..0a36cea1 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -74,4 +74,4 @@ jobs: run: poetry build - name: Publish a Python distribution to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f9d2a73..d21a8f8d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,4 +73,4 @@ jobs: run: poetry build - name: Publish a Python distribution to PyPI - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 From d53991be25f77090c6f913fd448c92e9d0d92759 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Thu, 4 Jun 2026 14:37:13 +0300 Subject: [PATCH 306/347] CM-65507 fire AI Guardrails SessionStart hook on all sources (resume/fork) (#470) --- cycode/cli/apps/ai_guardrails/ides/claude_code.py | 1 - cycode/cli/apps/ai_guardrails/ides/codex.py | 1 - tests/cli/commands/ai_guardrails/ides/test_codex.py | 7 ++++--- tests/cli/commands/ai_guardrails/test_hooks_manager.py | 5 +++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py index 4178ec56..a5a9c079 100644 --- a/cycode/cli/apps/ai_guardrails/ides/claude_code.py +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -239,7 +239,6 @@ def render_hooks_config(self, async_mode: bool = False) -> dict: 'hooks': { 'SessionStart': [ { - 'matcher': 'startup|clear', 'hooks': [{'type': 'command', 'command': _SESSION_START_COMMAND}], } ], diff --git a/cycode/cli/apps/ai_guardrails/ides/codex.py b/cycode/cli/apps/ai_guardrails/ides/codex.py index f8c9b04d..8be9f20a 100644 --- a/cycode/cli/apps/ai_guardrails/ides/codex.py +++ b/cycode/cli/apps/ai_guardrails/ides/codex.py @@ -201,7 +201,6 @@ def render_hooks_config(self, async_mode: bool = False) -> dict: 'hooks': { 'SessionStart': [ { - 'matcher': 'startup|clear', 'hooks': [{'type': 'command', 'command': _SESSION_START_COMMAND}], } ], diff --git a/tests/cli/commands/ai_guardrails/ides/test_codex.py b/tests/cli/commands/ai_guardrails/ides/test_codex.py index f71e19a2..5b656364 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_codex.py +++ b/tests/cli/commands/ai_guardrails/ides/test_codex.py @@ -146,10 +146,11 @@ def test_settings_path_honors_codex_home_env(fs: FakeFilesystem, monkeypatch: py # --- hooks config rendering -------------------------------------------------- -def test_render_hooks_session_start_matcher_includes_clear() -> None: - """SessionStart must fire on /clear too (conversation_id rotates).""" +def test_render_hooks_session_start_matches_all_sources() -> None: + """SessionStart must fire on every source (a forked session reports 'resume', + so no matcher is set -> match-all).""" rendered = Codex().render_hooks_config() - assert rendered['hooks']['SessionStart'][0]['matcher'] == 'startup|clear' + assert 'matcher' not in rendered['hooks']['SessionStart'][0] assert '--ide codex' in rendered['hooks']['SessionStart'][0]['hooks'][0]['command'] diff --git a/tests/cli/commands/ai_guardrails/test_hooks_manager.py b/tests/cli/commands/ai_guardrails/test_hooks_manager.py index f0a0248c..2097780a 100644 --- a/tests/cli/commands/ai_guardrails/test_hooks_manager.py +++ b/tests/cli/commands/ai_guardrails/test_hooks_manager.py @@ -111,11 +111,12 @@ def test_claude_code_render_hooks_async() -> None: def test_claude_code_render_hooks_session_start() -> None: - """Claude Code SessionStart fires on startup and /clear.""" + """Claude Code SessionStart fires on every source (a forked session reports + 'resume', so the matcher is empty -> match-all).""" config = ClaudeCode().render_hooks_config() entries = config['hooks']['SessionStart'] assert len(entries) == 1 - assert entries[0]['matcher'] == 'startup|clear' + assert 'matcher' not in entries[0] assert CYCODE_SESSION_START_COMMAND in entries[0]['hooks'][0]['command'] assert '--ide claude-code' in entries[0]['hooks'][0]['command'] From 67aab550d276e2f2041d523f2860b011861c08af Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:21:34 +0300 Subject: [PATCH 307/347] CM-64735 - Reduce sync scan latency (#472) Co-authored-by: Claude Opus 4.7 (1M context) --- cycode/__init__.py | 7 ++ cycode/cli/app.py | 103 ++++++++++++++++++---- cycode/cli/apps/scan/code_scanner.py | 27 +++--- cycode/cli/apps/scan/scan_parameters.py | 2 + cycode/cli/apps/scan/scan_result.py | 4 + cycode/cyclient/base_token_auth_client.py | 41 ++++++--- cycode/cyclient/cycode_client_base.py | 45 +++++----- pyinstaller.spec | 17 ++++ tests/cli/test_app_argv_peek.py | 82 +++++++++++++++++ 9 files changed, 266 insertions(+), 62 deletions(-) create mode 100644 tests/cli/test_app_argv_peek.py diff --git a/cycode/__init__.py b/cycode/__init__.py index 4ce71ef1..63ae25e0 100644 --- a/cycode/__init__.py +++ b/cycode/__init__.py @@ -1 +1,8 @@ +import time as _time + +# Unix-epoch wall clock captured at the earliest possible moment of CLI +# startup. Sent as `scan_parameters.cli_start_time` so the server can compute +# end-to-end scan duration from the moment the user actually triggered it. +_BOOT_WALL: float = _time.time() + __version__ = '0.0.0' # DON'T TOUCH. Placeholder. Will be filled automatically on poetry build from Git Tag diff --git a/cycode/cli/app.py b/cycode/cli/app.py index 103e8b86..82f7f41b 100644 --- a/cycode/cli/app.py +++ b/cycode/cli/app.py @@ -1,3 +1,4 @@ +import importlib import logging import sys from typing import Annotated, Optional @@ -10,12 +11,7 @@ from typer.completion import install_callback, show_callback from cycode import __version__ -from cycode.cli.apps import ai_guardrails, ai_remediation, auth, configure, ignore, report, report_import, scan, status from cycode.cli.apps.api import get_platform_group - -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 @@ -46,17 +42,88 @@ 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) -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) +# Top-level subcommand → module providing its Typer app. Peeking at sys.argv +# lets us import only the invoked subapp on the hot path (e.g. +# `cycode ai-guardrails scan`), skipping ~300ms of unrelated imports. +_SUBAPP_MODULES: dict[str, str] = { + 'ai-guardrails': 'cycode.cli.apps.ai_guardrails', + 'ai-remediation': 'cycode.cli.apps.ai_remediation', + 'auth': 'cycode.cli.apps.auth', + 'configure': 'cycode.cli.apps.configure', + 'ignore': 'cycode.cli.apps.ignore', + 'report': 'cycode.cli.apps.report', + 'import': 'cycode.cli.apps.report_import', + 'scan': 'cycode.cli.apps.scan', + 'status': 'cycode.cli.apps.status', +} if sys.version_info >= (3, 10): - app.add_typer(mcp.app) + _SUBAPP_MODULES['mcp'] = 'cycode.cli.apps.mcp' + +# Aliases: alternate spellings that resolve to a primary subcommand key. +_SUBAPP_ALIASES: dict[str, str] = { + 'ai_remediation': 'ai-remediation', # backward-compat underscore form + 'version': 'status', +} + +# Root-level options that consume a following value; argv-peek must skip past +# both the option and its value when scanning for the first positional arg. +_ROOT_OPTS_WITH_VALUE = frozenset( + { + '--output', + '-o', + '--user-agent', + '--client-secret', + '--client-id', + '--id-token', + '--show-completion', + } +) + + +def _detect_invocation() -> tuple[Optional[str], Optional[str]]: + """Return (top-level-subapp, second-level-subcommand) parsed from sys.argv. + + Both values may be None: when no positional arg matches a known subapp, + or when the user only provided a top-level subcommand. + """ + positionals = [] + args = sys.argv[1:] + i = 0 + while i < len(args): + arg = args[i] + if arg in _ROOT_OPTS_WITH_VALUE: + i += 2 + elif arg.startswith('-'): + # Any flag form: short, long, --key=value, or '--' marker. Skip the token only. + i += 1 + else: + positionals.append(arg) + if len(positionals) >= 2: + break + i += 1 + subapp = positionals[0] if positionals else None + subapp = _SUBAPP_ALIASES.get(subapp, subapp) + if subapp not in _SUBAPP_MODULES: + return None, None + subcommand = positionals[1] if len(positionals) >= 2 else None + return subapp, subcommand + + +# Computed once at import; reused by lazy registration and the version-checker skip. +_INVOKED_SUBAPP, _INVOKED_SUBCOMMAND = _detect_invocation() + + +def _register_subapps(only: Optional[str]) -> None: + if only is not None: + app.add_typer(importlib.import_module(_SUBAPP_MODULES[only]).app) + return + # Cold path (--help, completion, unknown subcommand): load all modules so + # root help lists everything. Deduplicate since aliases share modules. + for module_path in dict.fromkeys(_SUBAPP_MODULES.values()): + app.add_typer(importlib.import_module(module_path).app) + + +_register_subapps(_INVOKED_SUBAPP) # Register the `platform` command group (dynamically built from the OpenAPI spec). # The group itself is constructed cheaply at import time; the spec is only fetched @@ -81,6 +148,12 @@ def _get_group_with_platform(app_typer: typer.Typer) -> click.Group: def check_latest_version_on_close(ctx: typer.Context) -> None: + # Skip on `cycode ai-guardrails scan` — it emits JSON to stdout, so an + # upgrade notice would corrupt the response. Human-driven sibling commands + # (install, uninstall, status, session-start) still get the notice. + if (_INVOKED_SUBAPP, _INVOKED_SUBCOMMAND) == ('ai-guardrails', 'scan'): + return + output = ctx.obj.get('output') # don't print anything if the output is JSON if output == OutputTypeOption.JSON: diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index 072e438e..dc3727e4 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -204,18 +204,21 @@ def _scan_batch_thread_func(batch: list[Document]) -> tuple[str, CliError, Local '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, - ) + # Sync flows already received the full result inline; only async flows + # need a separate status report to signal polling completion. + if not should_use_sync_flow: + 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 diff --git a/cycode/cli/apps/scan/scan_parameters.py b/cycode/cli/apps/scan/scan_parameters.py index 58754e86..f362d419 100644 --- a/cycode/cli/apps/scan/scan_parameters.py +++ b/cycode/cli/apps/scan/scan_parameters.py @@ -2,6 +2,7 @@ import typer +from cycode import _BOOT_WALL 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 @@ -17,6 +18,7 @@ def _get_default_scan_parameters(ctx: typer.Context) -> dict: 'license_compliance': ctx.obj.get('license-compliance'), 'command_type': ctx.info_name.replace('-', '_'), # save backward compatibility 'aggregation_id': str(generate_unique_scan_id()), + 'cli_start_time': _BOOT_WALL, } diff --git a/cycode/cli/apps/scan/scan_result.py b/cycode/cli/apps/scan/scan_result.py index 13fb8576..9fb1da1d 100644 --- a/cycode/cli/apps/scan/scan_result.py +++ b/cycode/cli/apps/scan/scan_result.py @@ -189,6 +189,10 @@ def enrich_scan_result_with_data_from_detection_rules( for detection in detections_per_file.detections: detection_rule_ids.add(detection.detection_rule_id) + if not detection_rule_ids: + logger.debug('No detections to enrich, skipping detection_rules fetch') + return + 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} diff --git a/cycode/cyclient/base_token_auth_client.py b/cycode/cyclient/base_token_auth_client.py index 3f164836..ec315e7d 100644 --- a/cycode/cyclient/base_token_auth_client.py +++ b/cycode/cyclient/base_token_auth_client.py @@ -24,19 +24,10 @@ def __init__(self, client_id: str) -> None: 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._access_token = None + self._expires_in = None self._lock = Lock() + self._load_token_from_disk() def get_access_token(self) -> str: with self._lock: @@ -51,8 +42,30 @@ def invalidate_access_token(self, in_storage: bool = False) -> None: 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() + if self._has_valid_token(): + return + # Re-check disk before doing the network refresh: another client instance + # in this process may have already refreshed and persisted a fresh token. + self._load_token_from_disk() + if self._has_valid_token(): + return + self.refresh_access_token() + + def _has_valid_token(self) -> bool: + return self._access_token is not None and self._expires_in is not None and arrow.utcnow() < self._expires_in + + def _load_token_from_disk(self) -> None: + access_token, expires_in, creator = self._credentials_manager.get_access_token() + expected_creator = self._create_jwt_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. + if creator == expected_creator and access_token: + self._access_token = access_token + self._expires_in = arrow.get(expires_in) if expires_in else None + else: + self._access_token = None + self._expires_in = None def refresh_access_token(self) -> None: auth_response = self._request_new_access_token() diff --git a/cycode/cyclient/cycode_client_base.py b/cycode/cyclient/cycode_client_base.py index 1aae7bcb..bde0e880 100644 --- a/cycode/cyclient/cycode_client_base.py +++ b/cycode/cyclient/cycode_client_base.py @@ -1,3 +1,4 @@ +import functools import os import platform import ssl @@ -39,16 +40,29 @@ def cert_verify(self, *args, **kwargs) -> None: conn.ca_certs = None +@functools.cache +def _get_session() -> requests.Session: + """Process-wide Session so TCP+TLS connections are reused across all API calls.""" + session = requests.Session() + # On Windows without an explicit CA bundle env var, fall back to the system + # trust store via a custom SSL context. + if platform.system() == 'Windows' and not ( + os.environ.get('REQUESTS_CA_BUNDLE') or os.environ.get('CURL_CA_BUNDLE') + ): + session.mount('https://', SystemStorageSslContext()) + return session + + def _get_request_function() -> Callable: - if os.environ.get('REQUESTS_CA_BUNDLE') or os.environ.get('CURL_CA_BUNDLE'): - return requests.request + return _get_session().request - if platform.system() != 'Windows': - return requests.request - session = requests.Session() - session.mount('https://', SystemStorageSslContext()) - return session.request +def _log_response(response: Response, url: str, hide_response_content_log: bool) -> None: + content = 'HIDDEN' if hide_response_content_log else response.text + logger.debug( + 'Receiving response, %s', + {'status_code': response.status_code, 'url': url, 'content': content}, + ) _REQUEST_ERRORS_TO_RETRY = ( @@ -182,12 +196,7 @@ def _send_multipart( response = _get_request_function()( method='post', url=url, data=tracker, headers=headers, timeout=self.timeout ) - - content = 'HIDDEN' if hide_response_content_log else response.text - logger.debug( - 'Receiving response, %s', - {'status_code': response.status_code, 'url': url, 'content': content}, - ) + _log_response(response, url, hide_response_content_log) response.raise_for_status() return response @@ -231,14 +240,8 @@ 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 - logger.debug( - 'Receiving response, %s', - {'status_code': response.status_code, 'url': url, 'content': content}, - ) + response = _get_request_function()(method=method, url=url, timeout=timeout, headers=headers, **kwargs) + _log_response(response, url, hide_response_content_log) response.raise_for_status() return response diff --git a/pyinstaller.spec b/pyinstaller.spec index c577c547..d93766a8 100644 --- a/pyinstaller.spec +++ b/pyinstaller.spec @@ -21,9 +21,26 @@ CLI_VERSION = _dunamai.get_version('cycode', first_choice=_dunamai.Version.from_ with open(_INIT_FILE_PATH, 'w', encoding='UTF-8') as file: file.write(prev_content.replace(VERSION_PLACEHOLDER, CLI_VERSION)) +# Top-level subapp modules are loaded lazily via importlib.import_module() in +# cycode/cli/app.py to keep startup fast on hot paths (e.g. ai-guardrails scan). +# PyInstaller's static analyzer can't see those imports, so list them explicitly. +_hiddenimports = [ + 'cycode.cli.apps.ai_guardrails', + 'cycode.cli.apps.ai_remediation', + 'cycode.cli.apps.auth', + 'cycode.cli.apps.configure', + 'cycode.cli.apps.ignore', + 'cycode.cli.apps.report', + 'cycode.cli.apps.report_import', + 'cycode.cli.apps.scan', + 'cycode.cli.apps.status', + 'cycode.cli.apps.mcp', +] + a = Analysis( scripts=['cycode/cli/main.py'], excludes=['tests', 'setuptools', 'pkg_resources'], + hiddenimports=_hiddenimports, ) exe_args = [PYZ(a.pure), a.scripts, a.binaries, a.datas] diff --git a/tests/cli/test_app_argv_peek.py b/tests/cli/test_app_argv_peek.py new file mode 100644 index 00000000..bd4c61a8 --- /dev/null +++ b/tests/cli/test_app_argv_peek.py @@ -0,0 +1,82 @@ +"""Tests for the argv-peek lazy subapp registration in cycode/cli/app.py. + +The argv-peek picks the invoked subapp from sys.argv before Typer dispatches, +so it has to walk argv itself — skipping flags and (importantly) the values +those flags consume. The `_ROOT_OPTS_WITH_VALUE` set lists every root-level +flag that consumes a following positional token. If a maintainer adds a new +value-taking option to `app_callback` and forgets to register it here, the +argv-peek will silently fall back to the cold path (loading every subapp). +The test below catches that drift by comparing the hand-maintained set +against what Click's introspection sees on the built command. +""" + +from typing import Optional +from unittest.mock import patch + +import click +import pytest +import typer.main + +from cycode.cli.app import _ROOT_OPTS_WITH_VALUE, _detect_invocation, app + + +def test_root_opts_with_value_matches_click_introspection() -> None: + """Every root option that takes a value must be registered in _ROOT_OPTS_WITH_VALUE.""" + cmd = typer.main.get_command(app) + expected = { + opt + for param in cmd.params + if isinstance(param, click.Option) and not param.is_flag + for opt in param.opts + if opt.startswith('-') + } + assert frozenset(expected) == _ROOT_OPTS_WITH_VALUE, ( + f'_ROOT_OPTS_WITH_VALUE is out of sync with app_callback.\n' + f' Missing: {sorted(expected - _ROOT_OPTS_WITH_VALUE)}\n' + f' Extra: {sorted(_ROOT_OPTS_WITH_VALUE - expected)}\n' + f'Update _ROOT_OPTS_WITH_VALUE in cycode/cli/app.py.' + ) + + +@pytest.mark.parametrize( + 'argv', + [ + ['cycode', 'ai-guardrails', 'scan'], + ['cycode', '-v', 'ai-guardrails', 'scan'], + ['cycode', '--verbose', 'ai-guardrails', 'scan'], + ['cycode', '--output', 'json', 'ai-guardrails', 'scan'], + ['cycode', '-o', 'json', 'ai-guardrails', 'scan'], + ['cycode', '--user-agent', '{"app_name":"x"}', 'ai-guardrails', 'scan'], + ['cycode', '--client-secret', 'secret-val', 'ai-guardrails', 'scan'], + ['cycode', '--client-id', 'client-val', 'ai-guardrails', 'scan'], + ['cycode', '--id-token', 'token-val', 'ai-guardrails', 'scan'], + ['cycode', '--show-completion', 'bash', 'ai-guardrails', 'scan'], + # --key=value form is one token; argv-peek should treat it as a flag + ['cycode', '--output=json', 'ai-guardrails', 'scan'], + # multiple value-taking options stacked + ['cycode', '-v', '--output', 'json', '--client-id', 'foo', 'ai-guardrails', 'scan'], + ], +) +def test_detect_invocation_finds_subcommand_past_flags(argv: list[str]) -> None: + with patch('sys.argv', argv): + assert _detect_invocation() == ('ai-guardrails', 'scan') + + +@pytest.mark.parametrize( + ('argv', 'expected'), + [ + # No positional args → no match + (['cycode'], (None, None)), + (['cycode', '-v'], (None, None)), + # Unknown subapp → no match (graceful: app.py falls back to cold path) + (['cycode', 'not-a-real-subapp'], (None, None)), + # Known subapp, no subcommand + (['cycode', 'scan'], ('scan', None)), + # Alias resolution + (['cycode', 'ai_remediation'], ('ai-remediation', None)), + (['cycode', 'version'], ('status', None)), + ], +) +def test_detect_invocation_edge_cases(argv: list[str], expected: tuple[Optional[str], Optional[str]]) -> None: + with patch('sys.argv', argv): + assert _detect_invocation() == expected From d651b3ca6ab71994b989d89b2649b15f68c08988 Mon Sep 17 00:00:00 2001 From: RoniCycode <142726722+RoniCycode@users.noreply.github.com> Date: Sun, 14 Jun 2026 12:48:49 +0300 Subject: [PATCH 308/347] CM-65100-add-file-support-codex-and-claude (#463) Co-authored-by: Claude Opus 4.8 (1M context) --- .../apps/ai_guardrails/ides/_plugin_utils.py | 32 +++++++---- cycode/cli/apps/ai_guardrails/ides/base.py | 14 +++-- .../apps/ai_guardrails/ides/claude_code.py | 25 +++++---- cycode/cli/apps/ai_guardrails/ides/codex.py | 30 ++++++---- cycode/cli/apps/ai_guardrails/ides/cursor.py | 17 ++++-- .../ai_guardrails/session_start_command.py | 20 ++++++- cycode/cyclient/ai_security_manager_client.py | 12 +++- .../ai_guardrails/ides/test_claude_code.py | 42 +++++++++++++- .../commands/ai_guardrails/ides/test_codex.py | 56 +++++++++++++++++-- .../ai_guardrails/ides/test_contract.py | 9 ++- .../ai_guardrails/ides/test_cursor.py | 17 +++--- .../test_session_start_command.py | 38 ++++++++++--- 12 files changed, 239 insertions(+), 73 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py b/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py index 186dd37f..124fdc99 100644 --- a/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py +++ b/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py @@ -26,13 +26,26 @@ def load_plugin_json(path: Path) -> Optional[dict]: return None +def build_global_config_file(path: Path, mcp_servers: Optional[dict]) -> Optional[dict]: + """Wrap a global (non-plugin) MCP config into the session-context file shape. + + Returns ``{"path": , "content": <{"mcpServers": ...} JSON>}`` when + there are servers, else ``None``. ``content`` is normalized to the canonical + ``{"mcpServers": {...}}`` shape, dropping everything else in the source file. + """ + servers = mcp_servers or {} + if not servers: + return None + return {'path': str(path), 'content': json.dumps({'mcpServers': servers})} + + def walk_enabled_plugins( plugin_entries: dict[str, Any], is_enabled: Callable[[Any], bool], locate_dir: Callable[[str, str], Optional[Path]], read_plugin: Callable[[Path], tuple[dict, dict]], -) -> tuple[dict, dict]: - """Iterate enabled plugins; merge their MCP servers and metadata. +) -> dict: + """Iterate enabled plugins and build their inventory metadata. Args: plugin_entries: ``{@: settings}`` map from the IDE config. @@ -42,13 +55,13 @@ def walk_enabled_plugins( filesystem path or None if it can't be resolved. read_plugin: given the plugin path, returns ``(entry_fields, servers)``: ``entry_fields`` are extra metadata to attach to the inventory entry - (name/version/description/...), ``servers`` are MCP servers contributed. + (name/version/description/...); ``servers`` are the plugin's MCP + servers, which ``read_plugin`` uses to derive that metadata. - Returns ``(merged_mcp_servers, enriched_plugins)``. Plugin keys without - ``@`` (or that fail to resolve to a directory) still appear in the - inventory with just ``{'enabled': True}`` so we don't silently drop them. + Returns ``enriched_plugins``. Plugin keys without ``@`` (or that fail to + resolve to a directory) still appear in the inventory with just + ``{'enabled': True}`` so we don't silently drop them. """ - merged_mcp: dict = {} enriched: dict = {} for plugin_key, settings in plugin_entries.items(): @@ -66,8 +79,7 @@ def walk_enabled_plugins( if plugin_dir is None: continue - plugin_fields, servers = read_plugin(plugin_dir) + plugin_fields, _ = read_plugin(plugin_dir) entry.update(plugin_fields) - merged_mcp.update(servers) - return merged_mcp, enriched + return enriched diff --git a/cycode/cli/apps/ai_guardrails/ides/base.py b/cycode/cli/apps/ai_guardrails/ides/base.py index 92065590..28971db9 100644 --- a/cycode/cli/apps/ai_guardrails/ides/base.py +++ b/cycode/cli/apps/ai_guardrails/ides/base.py @@ -167,10 +167,16 @@ def get_user_email(self) -> Optional[str]: """ return None - def get_session_context(self) -> tuple[dict, dict]: - """Return ``(mcp_servers, enabled_plugins)`` for session-context reporting. + def get_session_context(self) -> tuple[Optional[dict], dict]: + """Return ``(global_config_file, enabled_plugins)`` for session-context reporting. - Default: empty dicts (no plugin system, no discoverable MCP config). + ``global_config_file`` is the IDE's global (non-plugin) MCP config as + ``{"path": , "content": }``, + or ``None`` when there is no global MCP config. ``enabled_plugins`` maps each + enabled plugin key to its metadata (including its own ``mcp_config_file`` + content and ``mcp_config_file_path``). + + Default: ``(None, {})`` (no plugin system, no discoverable MCP config). Override to surface MCP/plugin inventory. """ - return {}, {} + return None, {} diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py index a5a9c079..06989d87 100644 --- a/cycode/cli/apps/ai_guardrails/ides/claude_code.py +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -7,7 +7,11 @@ from typing import ClassVar, Optional from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND -from cycode.cli.apps.ai_guardrails.ides._plugin_utils import load_plugin_json, walk_enabled_plugins +from cycode.cli.apps.ai_guardrails.ides._plugin_utils import ( + build_global_config_file, + load_plugin_json, + walk_enabled_plugins, +) from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @@ -184,14 +188,17 @@ def _read_claude_plugin(plugin_dir: Path) -> tuple[dict, dict]: if field in manifest: entry[field] = manifest[field] - mcp_config = load_plugin_json(plugin_dir / '.mcp.json') or {} + mcp_config_path = plugin_dir / '.mcp.json' + mcp_config = load_plugin_json(mcp_config_path) or {} servers: dict = mcp_config.get('mcpServers') or {} if servers: entry['mcp_server_names'] = list(servers.keys()) + entry['mcp_config_file_path'] = str(mcp_config_path) + entry['mcp_config_file'] = json.dumps(mcp_config) return entry, servers -def resolve_plugins(settings: dict) -> tuple[dict, dict]: +def resolve_plugins(settings: dict) -> dict: """Walk Claude Code's ``enabledPlugins`` via the shared plugin walker. Each enabled plugin's marketplace is resolved through @@ -354,15 +361,11 @@ def get_user_email(self) -> Optional[str]: config = load_claude_config() return _email_from_config(config) if config else None - def get_session_context(self) -> tuple[dict, dict]: + def get_session_context(self) -> tuple[Optional[dict], dict]: config = load_claude_config() - mcp_servers: dict = dict(get_mcp_servers(config) or {}) if config else {} + global_config_file = build_global_config_file(_CLAUDE_CONFIG_PATH, get_mcp_servers(config)) if config else None settings = load_claude_settings() - if settings: - plugin_mcp, enriched_plugins = resolve_plugins(settings) - mcp_servers.update(plugin_mcp) - else: - enriched_plugins = {} + enriched_plugins = resolve_plugins(settings) if settings else {} - return mcp_servers, enriched_plugins + return global_config_file, enriched_plugins diff --git a/cycode/cli/apps/ai_guardrails/ides/codex.py b/cycode/cli/apps/ai_guardrails/ides/codex.py index 8be9f20a..2bfd70dc 100644 --- a/cycode/cli/apps/ai_guardrails/ides/codex.py +++ b/cycode/cli/apps/ai_guardrails/ides/codex.py @@ -14,7 +14,11 @@ import tomli as tomllib from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND -from cycode.cli.apps.ai_guardrails.ides._plugin_utils import load_plugin_json, walk_enabled_plugins +from cycode.cli.apps.ai_guardrails.ides._plugin_utils import ( + build_global_config_file, + load_plugin_json, + walk_enabled_plugins, +) from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @@ -129,16 +133,19 @@ def _read_codex_plugin(plugin_dir: Path) -> tuple[dict, dict]: mcp_ref = manifest.get('mcpServers') if not mcp_ref: return entry, {} - mcp_doc = load_plugin_json(plugin_dir / mcp_ref) or {} + mcp_config_path = plugin_dir / mcp_ref + mcp_doc = load_plugin_json(mcp_config_path) or {} servers = mcp_doc.get('mcpServers', mcp_doc) if not isinstance(servers, dict): servers = {} if servers: entry['mcp_server_names'] = list(servers.keys()) + entry['mcp_config_file_path'] = str(mcp_config_path) + entry['mcp_config_file'] = json.dumps(mcp_doc) return entry, servers -def _resolve_codex_plugins(config: dict) -> tuple[dict, dict]: +def _resolve_codex_plugins(config: dict) -> dict: """Walk enabled ``[plugins."@"]`` entries.""" return walk_enabled_plugins( plugin_entries=config.get('plugins') or {}, @@ -297,13 +304,14 @@ def build_session_payload(self, raw_payload: dict) -> AIHookPayload: def get_user_email(self) -> Optional[str]: return _email_from_auth() - def get_session_context(self) -> tuple[dict, dict]: + def get_session_context(self) -> tuple[Optional[dict], dict]: config = _load_codex_config() if not config: - return {}, {} - # Codex stores MCP servers under `[mcp_servers.]`. Plugin-contributed - # servers (via `[plugins."@"]`) merge on top. - mcp_servers: dict = dict(config.get('mcp_servers') or {}) - plugin_mcp, enriched_plugins = _resolve_codex_plugins(config) - mcp_servers.update(plugin_mcp) - return mcp_servers, enriched_plugins + return None, {} + # Codex stores MCP servers under `[mcp_servers.]`; the global config + # file becomes its own session-context file. Plugins (via + # `[plugins."@"]`) carry their own config files. + config_path = _codex_config_toml_path('user') + global_config_file = build_global_config_file(config_path, config.get('mcp_servers')) + enriched_plugins = _resolve_codex_plugins(config) + return global_config_file, enriched_plugins diff --git a/cycode/cli/apps/ai_guardrails/ides/cursor.py b/cycode/cli/apps/ai_guardrails/ides/cursor.py index aa218542..950e15bb 100644 --- a/cycode/cli/apps/ai_guardrails/ides/cursor.py +++ b/cycode/cli/apps/ai_guardrails/ides/cursor.py @@ -6,6 +6,7 @@ from typing import ClassVar, Optional from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND +from cycode.cli.apps.ai_guardrails.ides._plugin_utils import build_global_config_file from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @@ -39,9 +40,14 @@ def _user_hooks_dir() -> Path: return Path.home() / '.config' / 'Cursor' +def _cursor_mcp_config_path() -> Path: + """User-scope Cursor MCP config path (``~/.cursor/mcp.json``, all platforms).""" + return Path.home() / '.cursor' / _MCP_CONFIG_FILENAME + + def _load_cursor_mcp_config(config_path: Optional[Path] = None) -> Optional[dict]: """Load and parse `~/.cursor/mcp.json`. Returns None if missing/invalid.""" - path = config_path or (Path.home() / '.cursor' / _MCP_CONFIG_FILENAME) + path = config_path or _cursor_mcp_config_path() if not path.exists(): logger.debug('Cursor MCP config file not found, %s', {'path': str(path)}) return None @@ -113,7 +119,10 @@ def build_session_payload(self, raw_payload: dict) -> AIHookPayload: ide_version=raw_payload.get('cursor_version'), ) - def get_session_context(self) -> tuple[dict, dict]: + def get_session_context(self) -> tuple[Optional[dict], dict]: config = _load_cursor_mcp_config() - mcp_servers = dict((config or {}).get('mcpServers') or {}) if config else {} - return mcp_servers, {} + if not config: + return None, {} + config_path = _cursor_mcp_config_path() + global_config_file = build_global_config_file(config_path, config.get('mcpServers')) + return global_config_file, {} diff --git a/cycode/cli/apps/ai_guardrails/session_start_command.py b/cycode/cli/apps/ai_guardrails/session_start_command.py index cda53c62..3a20b2c8 100644 --- a/cycode/cli/apps/ai_guardrails/session_start_command.py +++ b/cycode/cli/apps/ai_guardrails/session_start_command.py @@ -1,5 +1,8 @@ """Handle AI guardrails session start: auth, conversation creation, session context.""" +import os +import platform +import socket import sys from typing import TYPE_CHECKING, Annotated, Optional @@ -20,14 +23,25 @@ logger = get_logger('AI Guardrails') +def _get_logged_in_user() -> Optional[str]: + """Best-effort OS account name (whoami). None if it can't be resolved.""" + try: + return os.getlogin() + except Exception: + return None + + def _report_session_context(ai_client: 'AISecurityManagerClient', ide: IDE, user_email: Optional[str]) -> None: """Report IDE session context to the AI security manager. Never raises.""" try: - mcp_servers, enabled_plugins = ide.get_session_context() - if not mcp_servers and not enabled_plugins: + global_config_file, enabled_plugins = ide.get_session_context() + if not global_config_file and not enabled_plugins: return ai_client.report_session_context( - mcp_servers=mcp_servers, + hostname=socket.gethostname(), + platform=platform.system(), + logged_in_user=_get_logged_in_user(), + global_config_file=global_config_file, enabled_plugins=enabled_plugins, user_email=user_email, ) diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index f9b7b124..19955410 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -93,15 +93,21 @@ def create_event( def report_session_context( self, - mcp_servers: Optional[dict] = None, + hostname: Optional[str] = None, + platform: Optional[str] = None, + logged_in_user: Optional[str] = None, + global_config_file: Optional[dict] = None, enabled_plugins: Optional[dict] = None, user_email: Optional[str] = None, ) -> None: """Report session context to the backend.""" body: dict = { - 'mcp_servers': mcp_servers, - 'enabled_plugins': enabled_plugins, + 'hostname': hostname, + 'platform': platform, + 'logged_in_user': logged_in_user, 'user_email': user_email, + 'global_config_file': global_config_file, + 'enabled_plugins': enabled_plugins, } try: diff --git a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py index f997abe3..d1e28c0b 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py +++ b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py @@ -8,7 +8,12 @@ from pytest_mock import MockerFixture from cycode.cli.apps.ai_guardrails.ides.base import HookDecision -from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode, _email_from_config, load_claude_config +from cycode.cli.apps.ai_guardrails.ides.claude_code import ( + ClaudeCode, + _email_from_config, + _read_claude_plugin, + load_claude_config, +) from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @@ -214,6 +219,37 @@ def test_email_none_when_no_oauth(mocker: MockerFixture) -> None: assert unified.ide_user_email is None +# _read_claude_plugin + + +def test_read_claude_plugin_includes_mcp_config_file(tmp_path: Path) -> None: + mcp_content = {'mcpServers': {'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}}} + (tmp_path / '.mcp.json').write_text(json.dumps(mcp_content)) + + entry, servers = _read_claude_plugin(tmp_path) + + assert 'mcp_config_file' in entry + assert json.loads(entry['mcp_config_file']) == mcp_content + assert entry['mcp_config_file_path'] == str(tmp_path / '.mcp.json') + assert servers == mcp_content['mcpServers'] + + +def test_read_claude_plugin_no_mcp_config_file_when_no_servers(tmp_path: Path) -> None: + (tmp_path / '.mcp.json').write_text(json.dumps({'mcpServers': {}})) + + entry, servers = _read_claude_plugin(tmp_path) + + assert 'mcp_config_file' not in entry + assert servers == {} + + +def test_read_claude_plugin_no_mcp_config_file_when_missing(tmp_path: Path) -> None: + entry, servers = _read_claude_plugin(tmp_path) + + assert 'mcp_config_file' not in entry + assert servers == {} + + # Session context @@ -222,8 +258,8 @@ def test_session_context_no_config() -> None: patch('cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_config', return_value=None), patch('cycode.cli.apps.ai_guardrails.ides.claude_code.load_claude_settings', return_value=None), ): - servers, plugins = ClaudeCode().get_session_context() - assert servers == {} + global_config_file, plugins = ClaudeCode().get_session_context() + assert global_config_file is None assert plugins == {} diff --git a/tests/cli/commands/ai_guardrails/ides/test_codex.py b/tests/cli/commands/ai_guardrails/ides/test_codex.py index 5b656364..285b889c 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_codex.py +++ b/tests/cli/commands/ai_guardrails/ides/test_codex.py @@ -16,6 +16,7 @@ _email_from_auth, _enable_codex_hooks_feature, _load_codex_config, + _read_codex_plugin, ) from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @@ -324,13 +325,60 @@ def test_session_context_reads_mcp_servers() -> None: 'cycode.cli.apps.ai_guardrails.ides.codex._load_codex_config', return_value={'mcp_servers': mcp}, ): - servers, plugins = Codex().get_session_context() - assert servers == mcp + global_config_file, plugins = Codex().get_session_context() + assert global_config_file is not None + assert global_config_file['path'].endswith('config.toml') + assert global_config_file['content'] == json.dumps({'mcpServers': mcp}) assert plugins == {} def test_session_context_no_config() -> None: with patch('cycode.cli.apps.ai_guardrails.ides.codex._load_codex_config', return_value=None): - servers, plugins = Codex().get_session_context() - assert servers == {} + global_config_file, plugins = Codex().get_session_context() + assert global_config_file is None assert plugins == {} + + +def _write_codex_plugin(plugin_dir: Path, mcp_doc: dict) -> None: + """Lay out a Codex plugin: manifest referencing .mcp.json + the MCP file itself.""" + (plugin_dir / '.codex-plugin').mkdir(parents=True, exist_ok=True) + (plugin_dir / '.codex-plugin' / 'plugin.json').write_text(json.dumps({'name': 'demo', 'mcpServers': '.mcp.json'})) + (plugin_dir / '.mcp.json').write_text(json.dumps(mcp_doc)) + + +def test_read_codex_plugin_includes_mcp_config_file(tmp_path: Path) -> None: + mcp_content = {'mcpServers': {'dummy-server': {'command': 'dummy-command', 'args': ['serve']}}} + _write_codex_plugin(tmp_path, mcp_content) + + entry, servers = _read_codex_plugin(tmp_path) + + assert json.loads(entry['mcp_config_file']) == mcp_content + assert entry['mcp_config_file_path'] == str(tmp_path / '.mcp.json') + assert servers == mcp_content['mcpServers'] + + +def test_read_codex_plugin_mcp_config_file_bare_map(tmp_path: Path) -> None: + # Codex MCP files may be a bare {name: cfg} map with no mcpServers wrapper. + mcp_content = {'dummy-server': {'command': 'dummy-command'}} + _write_codex_plugin(tmp_path, mcp_content) + + entry, servers = _read_codex_plugin(tmp_path) + + assert json.loads(entry['mcp_config_file']) == mcp_content + assert servers == mcp_content + + +def test_read_codex_plugin_no_mcp_config_file_when_no_servers(tmp_path: Path) -> None: + _write_codex_plugin(tmp_path, {'mcpServers': {}}) + + entry, servers = _read_codex_plugin(tmp_path) + + assert 'mcp_config_file' not in entry + assert servers == {} + + +def test_read_codex_plugin_no_mcp_config_file_when_no_manifest(tmp_path: Path) -> None: + entry, servers = _read_codex_plugin(tmp_path) + + assert 'mcp_config_file' not in entry + assert servers == {} diff --git a/tests/cli/commands/ai_guardrails/ides/test_contract.py b/tests/cli/commands/ai_guardrails/ides/test_contract.py index 7d7a5427..3bdbc19d 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_contract.py +++ b/tests/cli/commands/ai_guardrails/ides/test_contract.py @@ -105,9 +105,12 @@ def test_build_session_payload_tags_ide(ide: IDE) -> None: def test_get_session_context_returns_pair(ide: IDE) -> None: - """Session context must always be a ``(mcp_servers, plugins)`` 2-tuple of dicts.""" - mcp_servers, plugins = ide.get_session_context() - assert isinstance(mcp_servers, dict) + """Session context must be a ``(global_config_file, plugins)`` pair. + + ``global_config_file`` is ``None`` or a ``{"path", "content"}`` dict; ``plugins`` is a dict. + """ + global_config_file, plugins = ide.get_session_context() + assert global_config_file is None or isinstance(global_config_file, dict) assert isinstance(plugins, dict) diff --git a/tests/cli/commands/ai_guardrails/ides/test_cursor.py b/tests/cli/commands/ai_guardrails/ides/test_cursor.py index bb058f6f..4d082d3a 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_cursor.py +++ b/tests/cli/commands/ai_guardrails/ides/test_cursor.py @@ -133,22 +133,23 @@ def test_session_payload_carries_cursor_fields() -> None: assert session.ide_provider == 'cursor' -def test_session_context_loads_mcp_servers(tmp_path: Path) -> None: - """Cursor reads MCP servers from ~/.cursor/mcp.json.""" +def test_session_context_loads_mcp_servers() -> None: + """Cursor wraps ~/.cursor/mcp.json into a global_config_file.""" mcp_servers = {'github': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-github']}} - config_path = tmp_path / 'mcp.json' - config_path.write_text(json.dumps({'mcpServers': mcp_servers})) with patch('cycode.cli.apps.ai_guardrails.ides.cursor._load_cursor_mcp_config') as load: load.return_value = {'mcpServers': mcp_servers} - servers, plugins = Cursor().get_session_context() + global_config_file, plugins = Cursor().get_session_context() - assert servers == mcp_servers + assert global_config_file == { + 'path': str(Path.home() / '.cursor' / 'mcp.json'), + 'content': json.dumps({'mcpServers': mcp_servers}), + } assert plugins == {} def test_session_context_no_config_returns_empty() -> None: with patch('cycode.cli.apps.ai_guardrails.ides.cursor._load_cursor_mcp_config', return_value=None): - servers, plugins = Cursor().get_session_context() - assert servers == {} + global_config_file, plugins = Cursor().get_session_context() + assert global_config_file is None assert plugins == {} diff --git a/tests/cli/commands/ai_guardrails/test_session_start_command.py b/tests/cli/commands/ai_guardrails/test_session_start_command.py index 0ae57226..d048c8b3 100644 --- a/tests/cli/commands/ai_guardrails/test_session_start_command.py +++ b/tests/cli/commands/ai_guardrails/test_session_start_command.py @@ -3,7 +3,7 @@ import json from io import StringIO from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import ANY, MagicMock, patch import pytest import typer @@ -234,7 +234,13 @@ def test_claude_code_reports_mcp_servers( session_start_command(mock_ctx, ide='claude-code') mock_ai_client.report_session_context.assert_called_once_with( - mcp_servers=mcp_servers, + hostname=ANY, + platform=ANY, + logged_in_user=ANY, + global_config_file={ + 'path': str(_claude_mod._CLAUDE_CONFIG_PATH), + 'content': json.dumps({'mcpServers': mcp_servers}), + }, enabled_plugins={'cycode-dev@cycode-marketplace': {'enabled': True}}, user_email='test@test.com', ) @@ -244,7 +250,7 @@ def test_claude_code_reports_mcp_servers( @patch.object(_claude_mod, 'load_claude_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') -def test_claude_code_merges_plugin_mcp_servers_and_metadata( +def test_claude_code_reports_global_file_and_plugin_metadata( mock_get_auth: MagicMock, mock_get_client: MagicMock, mock_load_config: MagicMock, @@ -252,8 +258,8 @@ def test_claude_code_merges_plugin_mcp_servers_and_metadata( mock_ctx: MagicMock, tmp_path: Path, ) -> None: - """Plugin MCP servers from /.mcp.json should merge into mcp_servers, - and plugin metadata from .claude-plugin/plugin.json should enrich enabled_plugins.""" + """The global config file carries only the global MCP servers; the plugin's own + .mcp.json content + path + metadata enrich enabled_plugins (no merge into the global).""" mock_get_auth.return_value = MagicMock() mock_ai_client = MagicMock() mock_get_client.return_value = mock_ai_client @@ -282,10 +288,14 @@ def test_claude_code_merges_plugin_mcp_servers_and_metadata( with patch('sys.stdin', new=StringIO(json.dumps(payload))): session_start_command(mock_ctx, ide='claude-code') + plugin_mcp = {'mcpServers': {'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}}} mock_ai_client.report_session_context.assert_called_once_with( - mcp_servers={ - 'gitlab': {'command': 'npx'}, - 'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}, + hostname=ANY, + platform=ANY, + logged_in_user=ANY, + global_config_file={ + 'path': str(_claude_mod._CLAUDE_CONFIG_PATH), + 'content': json.dumps({'mcpServers': user_mcp_servers}), }, enabled_plugins={ 'cycode-dev@cycode-marketplace': { @@ -294,6 +304,8 @@ def test_claude_code_merges_plugin_mcp_servers_and_metadata( 'version': '1.0.28', 'description': 'Shared skills', 'mcp_server_names': ['aspire'], + 'mcp_config_file_path': str(plugin_dir / '.mcp.json'), + 'mcp_config_file': json.dumps(plugin_mcp), } }, user_email=None, @@ -348,7 +360,15 @@ def test_cursor_reports_mcp_servers( session_start_command(mock_ctx, ide='cursor') mock_ai_client.report_session_context.assert_called_once_with( - mcp_servers=mcp_servers, enabled_plugins={}, user_email=None + hostname=ANY, + platform=ANY, + logged_in_user=ANY, + global_config_file={ + 'path': str(Path.home() / '.cursor' / 'mcp.json'), + 'content': json.dumps({'mcpServers': mcp_servers}), + }, + enabled_plugins={}, + user_email=None, ) From 1c6435ed5ec35355542e11433c7eedfc7353b50c Mon Sep 17 00:00:00 2001 From: Mateusz Sterczewski Date: Tue, 16 Jun 2026 10:44:48 +0200 Subject: [PATCH 309/347] CM-65436: add SAST fallback ignore-extensions list (#473) Co-authored-by: Claude Sonnet 4.6 --- cycode/cli/consts.py | 24 +++++++++++++++++++++ cycode/cli/files_collector/file_excluder.py | 8 +++++++ 2 files changed, 32 insertions(+) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 52a6827d..9ef19eb2 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -53,6 +53,30 @@ '.iso', ) +# Fallback block-list used for SAST only when the server does not return scannable extensions +# (e.g. when the customer has custom rules, any text file is scannable). These are non-source +# data formats that can slip past binary detection (the EICAR test file and ClamAV signature +# databases are plain ASCII) and may be quarantined by object-storage antivirus after upload. +SAST_SCAN_FILE_EXTENSIONS_TO_IGNORE = ( + '.bin', + '.cvd', + '.cld', + '.cud', + '.hdb', + '.hsb', + '.mdb', + '.msb', + '.ndb', + '.ndu', + '.ldb', + '.ldu', + '.idb', + '.fp', + '.sfp', + '.ign', + '.ign2', +) + SCA_CONFIGURATION_SCAN_SUPPORTED_FILES = ( # keep in lowercase 'cargo.lock', 'cargo.toml', diff --git a/cycode/cli/files_collector/file_excluder.py b/cycode/cli/files_collector/file_excluder.py index 11fd3410..fc61f0e2 100644 --- a/cycode/cli/files_collector/file_excluder.py +++ b/cycode/cli/files_collector/file_excluder.py @@ -63,7 +63,10 @@ def __init__(self) -> None: } self._non_scannable_extensions: dict[str, tuple[str, ...]] = { consts.SECRET_SCAN_TYPE: consts.SECRET_SCAN_FILE_EXTENSIONS_TO_IGNORE, + consts.SAST_SCAN_TYPE: consts.SAST_SCAN_FILE_EXTENSIONS_TO_IGNORE, } + # Tracks scan types for which the SAST fallback log has already been emitted (log once, not per file) + self._logged_sast_fallback = False def apply_scan_config(self, scan_type: str, scan_config: 'models.ScanConfiguration') -> None: if scan_config.scannable_extensions: @@ -86,6 +89,11 @@ def _is_file_extension_supported(self, scan_type: str, filename: str) -> bool: non_scannable_extensions = self._non_scannable_extensions.get(scan_type) if non_scannable_extensions: + # For SAST, reaching the block-list means the server returned no scannable extensions + # (e.g. custom rules, or no remote config). Log once so this is diagnosable. + if scan_type == consts.SAST_SCAN_TYPE and not self._logged_sast_fallback: + self._logged_sast_fallback = True + logger.debug('No scannable extensions provided for SAST; falling back to the built-in ignore list') return not filename.endswith(non_scannable_extensions) return True From d7f407681db37d8dae59a27a14229cd16e2a10bf Mon Sep 17 00:00:00 2001 From: Ilia Shkolyar <60312091+ilia-cy@users.noreply.github.com> Date: Sun, 21 Jun 2026 15:45:57 +0300 Subject: [PATCH 310/347] CM-67318: Warn when env vars override configured credentials (#474) Co-authored-by: Claude Opus 4.8 --- .../cli/apps/configure/configure_command.py | 18 +++++++++++- cycode/cli/apps/configure/messages.py | 20 +++++++++---- tests/cli/commands/configure/test_messages.py | 28 +++++++++++++++++++ 3 files changed, 59 insertions(+), 7 deletions(-) create mode 100644 tests/cli/commands/configure/test_messages.py diff --git a/cycode/cli/apps/configure/configure_command.py b/cycode/cli/apps/configure/configure_command.py index 1811271c..3c2f269b 100644 --- a/cycode/cli/apps/configure/configure_command.py +++ b/cycode/cli/apps/configure/configure_command.py @@ -1,7 +1,12 @@ from typing import Optional 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.messages import ( + get_credentials_environment_variables_override_warning, + get_credentials_update_result_message, + get_urls_environment_variables_override_warning, + get_urls_update_result_message, +) from cycode.cli.apps.configure.prompts import ( get_api_url_input, get_app_url_input, @@ -73,3 +78,14 @@ def configure_command() -> None: console.print(get_urls_update_result_message()) if credentials_updated or oidc_credentials_updated: console.print(get_credentials_update_result_message()) + + # Warn about environment variables that override the configured file values, regardless of whether anything was + # updated. The env vars take precedence on every subsequent call, so configuring the file alone has no effect while + # they are set. + urls_override_warning = get_urls_environment_variables_override_warning() + if urls_override_warning: + console.print(f'[yellow]Warning:[/] {urls_override_warning}') + + credentials_override_warning = get_credentials_environment_variables_override_warning() + if credentials_override_warning: + console.print(f'[yellow]Warning:[/] {credentials_override_warning}') diff --git a/cycode/cli/apps/configure/messages.py b/cycode/cli/apps/configure/messages.py index 36ce807b..f008f09d 100644 --- a/cycode/cli/apps/configure/messages.py +++ b/cycode/cli/apps/configure/messages.py @@ -1,3 +1,5 @@ +from typing import Optional + from cycode.cli.apps.configure.consts import ( CONFIGURATION_MANAGER, CREDENTIALS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE, @@ -14,11 +16,14 @@ def _are_credentials_exist_in_environment_variables() -> bool: def get_credentials_update_result_message() -> str: - success_message = CREDENTIALS_UPDATED_SUCCESSFULLY_MESSAGE.format(filename=CREDENTIALS_MANAGER.get_filename()) + return CREDENTIALS_UPDATED_SUCCESSFULLY_MESSAGE.format(filename=CREDENTIALS_MANAGER.get_filename()) + + +def get_credentials_environment_variables_override_warning() -> Optional[str]: if _are_credentials_exist_in_environment_variables(): - return f'{success_message}. {CREDENTIALS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE}' + return CREDENTIALS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE - return success_message + return None def _are_urls_exist_in_environment_variables() -> bool: @@ -28,10 +33,13 @@ def _are_urls_exist_in_environment_variables() -> bool: def get_urls_update_result_message() -> str: - success_message = URLS_UPDATED_SUCCESSFULLY_MESSAGE.format( + return URLS_UPDATED_SUCCESSFULLY_MESSAGE.format( filename=CONFIGURATION_MANAGER.global_config_file_manager.get_filename() ) + + +def get_urls_environment_variables_override_warning() -> Optional[str]: if _are_urls_exist_in_environment_variables(): - return f'{success_message}. {URLS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE}' + return URLS_ARE_SET_IN_ENVIRONMENT_VARIABLES_MESSAGE - return success_message + return None diff --git a/tests/cli/commands/configure/test_messages.py b/tests/cli/commands/configure/test_messages.py new file mode 100644 index 00000000..9eb0190a --- /dev/null +++ b/tests/cli/commands/configure/test_messages.py @@ -0,0 +1,28 @@ +from typing import TYPE_CHECKING + +from cycode.cli.apps.configure import messages +from cycode.cli.config import CYCODE_CLIENT_ID_ENV_VAR_NAME, CYCODE_CLIENT_SECRET_ENV_VAR_NAME + +if TYPE_CHECKING: + import pytest + + +def test_credentials_override_warning_absent_when_no_env_vars(monkeypatch: 'pytest.MonkeyPatch') -> None: + monkeypatch.delenv(CYCODE_CLIENT_ID_ENV_VAR_NAME, raising=False) + monkeypatch.delenv(CYCODE_CLIENT_SECRET_ENV_VAR_NAME, raising=False) + + assert messages.get_credentials_environment_variables_override_warning() is None + + +def test_credentials_override_warning_present_when_only_client_id_set(monkeypatch: 'pytest.MonkeyPatch') -> None: + monkeypatch.setenv(CYCODE_CLIENT_ID_ENV_VAR_NAME, 'env-client-id') + monkeypatch.delenv(CYCODE_CLIENT_SECRET_ENV_VAR_NAME, raising=False) + + assert messages.get_credentials_environment_variables_override_warning() is not None + + +def test_credentials_success_message_does_not_embed_override_warning(monkeypatch: 'pytest.MonkeyPatch') -> None: + monkeypatch.setenv(CYCODE_CLIENT_ID_ENV_VAR_NAME, 'env-client-id') + monkeypatch.setenv(CYCODE_CLIENT_SECRET_ENV_VAR_NAME, 'env-client-secret') + + assert 'environment variables' not in messages.get_credentials_update_result_message() From 9f34d7f616de50ddb80fea185cd6e9b4ba46f1b6 Mon Sep 17 00:00:00 2001 From: RoniCycode <142726722+RoniCycode@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:10:29 +0300 Subject: [PATCH 311/347] CM-67459: Enrich session context payload (#477) --- .../ai_guardrails/session_start_command.py | 26 ++-- cycode/cli/utils/host_info.py | 127 ++++++++++++++++++ cycode/cyclient/ai_security_manager_client.py | 12 +- .../test_session_start_command.py | 18 ++- 4 files changed, 159 insertions(+), 24 deletions(-) create mode 100644 cycode/cli/utils/host_info.py diff --git a/cycode/cli/apps/ai_guardrails/session_start_command.py b/cycode/cli/apps/ai_guardrails/session_start_command.py index 3a20b2c8..5d491f10 100644 --- a/cycode/cli/apps/ai_guardrails/session_start_command.py +++ b/cycode/cli/apps/ai_guardrails/session_start_command.py @@ -1,8 +1,5 @@ """Handle AI guardrails session start: auth, conversation creation, session context.""" -import os -import platform -import socket import sys from typing import TYPE_CHECKING, Annotated, Optional @@ -15,6 +12,13 @@ from cycode.cli.apps.auth.auth_manager import AuthManager from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception from cycode.cli.utils.get_api_client import get_ai_security_manager_client +from cycode.cli.utils.host_info import ( + get_hostname, + get_last_login_user, + get_os_version, + get_platform_name, + get_serial_number, +) from cycode.logger import get_logger if TYPE_CHECKING: @@ -23,14 +27,6 @@ logger = get_logger('AI Guardrails') -def _get_logged_in_user() -> Optional[str]: - """Best-effort OS account name (whoami). None if it can't be resolved.""" - try: - return os.getlogin() - except Exception: - return None - - def _report_session_context(ai_client: 'AISecurityManagerClient', ide: IDE, user_email: Optional[str]) -> None: """Report IDE session context to the AI security manager. Never raises.""" try: @@ -38,9 +34,11 @@ def _report_session_context(ai_client: 'AISecurityManagerClient', ide: IDE, user if not global_config_file and not enabled_plugins: return ai_client.report_session_context( - hostname=socket.gethostname(), - platform=platform.system(), - logged_in_user=_get_logged_in_user(), + hostname=get_hostname(), + platform_name=get_platform_name(), + os_version=get_os_version(), + serial_number=get_serial_number(), + last_login_user=get_last_login_user(), global_config_file=global_config_file, enabled_plugins=enabled_plugins, user_email=user_email, diff --git a/cycode/cli/utils/host_info.py b/cycode/cli/utils/host_info.py new file mode 100644 index 00000000..23737b7a --- /dev/null +++ b/cycode/cli/utils/host_info.py @@ -0,0 +1,127 @@ +import getpass +import platform +import re +import socket +import subprocess +from typing import Optional + +from cycode.logger import get_logger + +logger = get_logger('HOST INFO') + +_SUBPROCESS_TIMEOUT_SEC = 5 + +_PLATFORM_NAMES = {'Darwin': 'macOS', 'Windows': 'Windows', 'Linux': 'Linux'} + + +def _run(command: list, timeout: int = _SUBPROCESS_TIMEOUT_SEC) -> Optional[str]: + """Run a command and return its stripped stdout. Never raises; returns None on any error.""" + try: + result = subprocess.run(command, capture_output=True, text=True, timeout=timeout) # noqa: S603 + return result.stdout.strip() or None + except Exception as e: + logger.debug('Failed to run command %s', command, exc_info=e) + return None + + +def _read_text_file(path: str) -> Optional[str]: + """Read and strip a text file. Never raises; returns None if it can't be read.""" + try: + with open(path) as text_file: + return text_file.read().strip() or None + except OSError: + return None + + +def get_hostname() -> Optional[str]: + try: + return socket.gethostname() or None + except Exception as e: + logger.debug('Failed to resolve hostname', exc_info=e) + return None + + +def get_platform_name() -> Optional[str]: + try: + system = platform.system() + return _PLATFORM_NAMES.get(system, system or None) + except Exception as e: + logger.debug('Failed to resolve platform name', exc_info=e) + return None + + +def get_os_version() -> Optional[str]: + try: + system = platform.system() + if system == 'Darwin': + return platform.mac_ver()[0] or None + if system == 'Windows': + return platform.win32_ver()[1] or platform.version() or None + if system == 'Linux': + return _get_linux_os_version() + return platform.release() or None + except Exception as e: + logger.debug('Failed to resolve OS version', exc_info=e) + return None + + +def _get_linux_os_version() -> Optional[str]: + freedesktop_os_release = getattr(platform, 'freedesktop_os_release', None) # Python 3.10+ + if freedesktop_os_release is not None: + try: + version_id = freedesktop_os_release().get('VERSION_ID') + if version_id: + return version_id + except OSError: + pass + + os_release = _read_text_file('/etc/os-release') # Python 3.9 fallback: parse manually + if os_release: + for line in os_release.splitlines(): + if line.startswith('VERSION_ID='): + return line.split('=', 1)[1].strip().strip('"') or None + + return platform.release() or None + + +def get_last_login_user() -> Optional[str]: + try: + return getpass.getuser() or None + except Exception as e: + logger.debug('Failed to resolve last login user', exc_info=e) + return None + + +def get_serial_number() -> Optional[str]: + try: + system = platform.system() + if system == 'Darwin': + return _get_macos_serial_number() + if system == 'Windows': + return _get_windows_serial_number() + except Exception as e: + logger.debug('Failed to resolve serial number', exc_info=e) + return None + + +def _get_macos_serial_number() -> Optional[str]: + output = _run(['ioreg', '-c', 'IOPlatformExpertDevice', '-d', '2']) + if not output: + return None + match = re.search(r'"IOPlatformSerialNumber"\s*=\s*"([^"]+)"', output) + return match.group(1) if match else None + + +def _get_windows_serial_number() -> Optional[str]: + import pythoncom # from pywin32 + import win32com.client # from pywin32 + + pythoncom.CoInitialize() + try: + wmi_service = win32com.client.GetObject('winmgmts:') + for bios in wmi_service.InstancesOf('Win32_BIOS'): + serial = bios.SerialNumber + return serial.strip() if serial else None + finally: + pythoncom.CoUninitialize() + return None diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index 19955410..a4f9bd76 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -94,8 +94,10 @@ def create_event( def report_session_context( self, hostname: Optional[str] = None, - platform: Optional[str] = None, - logged_in_user: Optional[str] = None, + platform_name: Optional[str] = None, + os_version: Optional[str] = None, + serial_number: Optional[str] = None, + last_login_user: Optional[str] = None, global_config_file: Optional[dict] = None, enabled_plugins: Optional[dict] = None, user_email: Optional[str] = None, @@ -103,8 +105,10 @@ def report_session_context( """Report session context to the backend.""" body: dict = { 'hostname': hostname, - 'platform': platform, - 'logged_in_user': logged_in_user, + 'platform_name': platform_name, + 'os_version': os_version, + 'serial_number': serial_number, + 'last_login_user': last_login_user, 'user_email': user_email, 'global_config_file': global_config_file, 'enabled_plugins': enabled_plugins, diff --git a/tests/cli/commands/ai_guardrails/test_session_start_command.py b/tests/cli/commands/ai_guardrails/test_session_start_command.py index d048c8b3..ed6708ce 100644 --- a/tests/cli/commands/ai_guardrails/test_session_start_command.py +++ b/tests/cli/commands/ai_guardrails/test_session_start_command.py @@ -235,8 +235,10 @@ def test_claude_code_reports_mcp_servers( mock_ai_client.report_session_context.assert_called_once_with( hostname=ANY, - platform=ANY, - logged_in_user=ANY, + platform_name=ANY, + os_version=ANY, + serial_number=ANY, + last_login_user=ANY, global_config_file={ 'path': str(_claude_mod._CLAUDE_CONFIG_PATH), 'content': json.dumps({'mcpServers': mcp_servers}), @@ -291,8 +293,10 @@ def test_claude_code_reports_global_file_and_plugin_metadata( plugin_mcp = {'mcpServers': {'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}}} mock_ai_client.report_session_context.assert_called_once_with( hostname=ANY, - platform=ANY, - logged_in_user=ANY, + platform_name=ANY, + os_version=ANY, + serial_number=ANY, + last_login_user=ANY, global_config_file={ 'path': str(_claude_mod._CLAUDE_CONFIG_PATH), 'content': json.dumps({'mcpServers': user_mcp_servers}), @@ -361,8 +365,10 @@ def test_cursor_reports_mcp_servers( mock_ai_client.report_session_context.assert_called_once_with( hostname=ANY, - platform=ANY, - logged_in_user=ANY, + platform_name=ANY, + os_version=ANY, + serial_number=ANY, + last_login_user=ANY, global_config_file={ 'path': str(Path.home() / '.cursor' / 'mcp.json'), 'content': json.dumps({'mcpServers': mcp_servers}), From 641747d5803c411a10bad96a7263494c97bc6dd9 Mon Sep 17 00:00:00 2001 From: Arad Traub Date: Mon, 29 Jun 2026 16:25:20 +0300 Subject: [PATCH 312/347] CM-67195: Add Bun package manager support to SCA local scans (#478) Co-authored-by: Claude Opus 4.8 (1M context) --- cycode/cli/consts.py | 2 + .../sca/npm/restore_bun_dependencies.py | 113 ++++++++++ .../sca/npm/restore_npm_dependencies.py | 11 +- .../files_collector/sca/sca_file_collector.py | 4 +- .../sca/npm/test_restore_bun_dependencies.py | 205 ++++++++++++++++++ .../sca/npm/test_restore_npm_dependencies.py | 9 + 6 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py create mode 100644 tests/cli/files_collector/sca/npm/test_restore_bun_dependencies.py diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 9ef19eb2..a134f3f4 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -102,6 +102,7 @@ 'deno.lock', 'deno.json', 'pnpm-lock.yaml', + 'bun.lock', 'npm-shrinkwrap.json', 'packages.config', 'project.assets.json', @@ -165,6 +166,7 @@ 'npm-shrinkwrap.json', '.npmrc', 'pnpm-lock.yaml', + 'bun.lock', 'deno.lock', 'deno.json', ], diff --git a/cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py b/cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py new file mode 100644 index 00000000..2bf0d647 --- /dev/null +++ b/cycode/cli/files_collector/sca/npm/restore_bun_dependencies.py @@ -0,0 +1,113 @@ +import json +import re +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.cli.utils.shell_executor import shell +from cycode.logger import get_logger + +logger = get_logger('Bun Restore Dependencies') + +BUN_MANIFEST_FILE_NAME = 'package.json' +BUN_LOCK_FILE_NAME = 'bun.lock' + +# Only Bun >=1.2 produces the text-based `bun.lock` lockfile that we parse. +# Older Bun versions emit a binary `bun.lockb`, which is not supported. +MINIMUM_BUN_VERSION = (1, 2) +BUN_VERSION_COMMAND = ['bun', '--version'] + + +def _indicates_bun(package_json_content: Optional[str]) -> bool: + """Return True if package.json content signals that this project uses Bun.""" + 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('bun'): + return True + + engines = data.get('engines', {}) + return isinstance(engines, dict) and 'bun' in engines + + +def _parse_bun_version(raw_version: Optional[str]) -> Optional[tuple[int, int]]: + """Parse the (major, minor) version from `bun --version` output (e.g. '1.2.3').""" + if not raw_version: + return None + match = re.match(r'(\d+)\.(\d+)', raw_version.strip()) + if not match: + return None + return int(match.group(1)), int(match.group(2)) + + +class RestoreBunDependencies(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 != BUN_MANIFEST_FILE_NAME: + return False + + manifest_dir = self.get_manifest_dir(document) + if manifest_dir and (Path(manifest_dir) / BUN_LOCK_FILE_NAME).is_file(): + return True + + return _indicates_bun(document.content) + + def _is_supported_bun_version(self) -> bool: + """Verify that the installed Bun is >=1.2, which is required to generate a text bun.lock.""" + raw_version = shell(command=BUN_VERSION_COMMAND, timeout=self.command_timeout, silent_exc_info=True) + version = _parse_bun_version(raw_version) + minimum = '.'.join(str(part) for part in MINIMUM_BUN_VERSION) + if version is None: + logger.warning( + 'Could not determine Bun version; Bun %s+ is required to restore Bun dependencies, %s', + minimum, + {'raw_version': raw_version}, + ) + return False + if version < MINIMUM_BUN_VERSION: + logger.warning( + 'Unsupported Bun version; Bun %s+ is required to restore Bun dependencies, %s', + minimum, + {'detected_version': '.'.join(str(part) for part in version)}, + ) + return False + return True + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / BUN_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + # Lockfile already exists — read it directly without running bun. + # A text bun.lock only exists when generated by Bun >=1.2, so no version check is needed here. + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, BUN_LOCK_FILE_NAME) + logger.debug('Using existing bun.lock, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + # Lockfile absent — must generate it via `bun install`. This requires Bun >=1.2, + # otherwise an older Bun would emit a binary bun.lockb that we cannot parse. + if not self._is_supported_bun_version(): + return None + + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + return [['bun', 'install', '--ignore-scripts']] + + def get_lock_file_name(self) -> str: + return BUN_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [BUN_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 d07bc4a5..9416f58c 100644 --- a/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py +++ b/cycode/cli/files_collector/sca/npm/restore_npm_dependencies.py @@ -11,7 +11,7 @@ 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') +_ALTERNATIVE_LOCK_FILES = ('yarn.lock', 'pnpm-lock.yaml', 'deno.lock', 'bun.lock') class RestoreNpmDependencies(BaseRestoreDependencies): @@ -23,6 +23,15 @@ def is_project(self, document: Document) -> bool: 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. + + NOTE: this guard only excludes a project when an alternative lockfile is *physically + present on disk*. It does not inspect the `packageManager`/`engines` signal in + package.json. So a project that declares e.g. `packageManager: "bun@..."` (or pnpm) + but has no lockfile yet is claimed by BOTH the dedicated handler and this npm fallback, + and both restores run. This is pre-existing behavior shared by pnpm/yarn/bun and is + accepted for now (a real Bun/pnpm project ships a lockfile, so npm correctly skips). + If this ever needs tightening, also skip here when package.json declares a non-npm + packageManager/engines signal. """ if Path(document.path).name != NPM_MANIFEST_FILE_NAME: return False diff --git a/cycode/cli/files_collector/sca/sca_file_collector.py b/cycode/cli/files_collector/sca/sca_file_collector.py index b57061b0..6bcfd494 100644 --- a/cycode/cli/files_collector/sca/sca_file_collector.py +++ b/cycode/cli/files_collector/sca/sca_file_collector.py @@ -10,6 +10,7 @@ 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_bun_dependencies import RestoreBunDependencies 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 @@ -157,8 +158,9 @@ def _get_restore_handlers(ctx: typer.Context, is_git_diff: bool) -> list[BaseRes RestoreNugetDependencies(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), + RestoreBunDependencies(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 + RestoreNpmDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be after Yarn, Pnpm & Bun for fallback RestoreRubyDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestoreUvDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be before Poetry for pyproject.toml RestorePoetryDependencies(ctx, is_git_diff, build_dep_tree_timeout), diff --git a/tests/cli/files_collector/sca/npm/test_restore_bun_dependencies.py b/tests/cli/files_collector/sca/npm/test_restore_bun_dependencies.py new file mode 100644 index 00000000..17f189df --- /dev/null +++ b/tests/cli/files_collector/sca/npm/test_restore_bun_dependencies.py @@ -0,0 +1,205 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.npm.restore_bun_dependencies import ( + BUN_LOCK_FILE_NAME, + RestoreBunDependencies, + _parse_bun_version, +) +from cycode.cli.models import Document + +_BUN_MODULE = 'cycode.cli.files_collector.sca.npm.restore_bun_dependencies' + + +@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_bun(mock_ctx: typer.Context) -> RestoreBunDependencies: + return RestoreBunDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_package_json_with_bun_lock_matches(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'bun.lock').write_text('{"lockfileVersion": 1}\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + assert restore_bun.is_project(doc) is True + + def test_package_json_with_package_manager_bun_matches(self, restore_bun: RestoreBunDependencies) -> None: + content = '{"name": "test", "packageManager": "bun@1.1.0"}' + doc = Document('package.json', content) + assert restore_bun.is_project(doc) is True + + def test_package_json_with_engines_bun_matches(self, restore_bun: RestoreBunDependencies) -> None: + content = '{"name": "test", "engines": {"bun": ">=1"}}' + doc = Document('package.json', content) + assert restore_bun.is_project(doc) is True + + def test_package_json_with_no_bun_signal_does_not_match( + self, restore_bun: RestoreBunDependencies, 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_bun.is_project(doc) is False + + def test_package_json_with_yarn_lock_does_not_match( + self, restore_bun: RestoreBunDependencies, 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_bun.is_project(doc) is False + + def test_tsconfig_json_does_not_match(self, restore_bun: RestoreBunDependencies) -> None: + doc = Document('tsconfig.json', '{"compilerOptions": {}}') + assert restore_bun.is_project(doc) is False + + def test_package_manager_yarn_does_not_match(self, restore_bun: RestoreBunDependencies) -> None: + content = '{"name": "test", "packageManager": "yarn@4.0.0"}' + doc = Document('package.json', content) + assert restore_bun.is_project(doc) is False + + def test_invalid_json_content_does_not_match(self, restore_bun: RestoreBunDependencies) -> None: + doc = Document('package.json', 'not valid json') + assert restore_bun.is_project(doc) is False + + +class TestTryRestoreDependencies: + def test_existing_bun_lock_returned_directly(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + bun_lock_content = '{"lockfileVersion": 1, "packages": {"package": ["package@1.0.0", "", {}, ""]}}\n' + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'bun.lock').write_text(bun_lock_content) + + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + result = restore_bun.try_restore_dependencies(doc) + + assert result is not None + assert BUN_LOCK_FILE_NAME in result.path + assert result.content == bun_lock_content + + def test_get_lock_file_name(self, restore_bun: RestoreBunDependencies) -> None: + assert restore_bun.get_lock_file_name() == BUN_LOCK_FILE_NAME + + def test_get_commands_returns_bun_install(self, restore_bun: RestoreBunDependencies) -> None: + commands = restore_bun.get_commands('/path/to/package.json') + assert commands == [['bun', 'install', '--ignore-scripts']] + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestParseBunVersion: + def test_parses_full_semver(self) -> None: + assert _parse_bun_version('1.2.3') == (1, 2) + + def test_parses_with_surrounding_whitespace(self) -> None: + assert _parse_bun_version(' 1.2.0\n') == (1, 2) + + def test_none_input_returns_none(self) -> None: + assert _parse_bun_version(None) is None + + def test_non_version_string_returns_none(self) -> None: + assert _parse_bun_version('not-a-version') is None + + +class TestBunVersionGate: + def test_supported_version_proceeds_to_restore(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + content = '{"name": "test", "packageManager": "bun@1.2.0"}' + (tmp_path / 'package.json').write_text(content) + doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json')) + + with ( + patch(f'{_BUN_MODULE}.shell', return_value='1.2.5'), + patch.object( + restore_bun.__class__.__bases__[0], 'try_restore_dependencies', return_value=None + ) as mock_super, + ): + restore_bun.try_restore_dependencies(doc) + mock_super.assert_called_once_with(doc) + + def test_old_version_skips_restore(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + content = '{"name": "test", "packageManager": "bun@1.1.0"}' + (tmp_path / 'package.json').write_text(content) + doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json')) + + with ( + patch(f'{_BUN_MODULE}.shell', return_value='1.1.38'), + patch.object(restore_bun.__class__.__bases__[0], 'try_restore_dependencies') as mock_super, + ): + result = restore_bun.try_restore_dependencies(doc) + assert result is None + mock_super.assert_not_called() + + def test_missing_bun_skips_restore(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + content = '{"name": "test", "packageManager": "bun@1.2.0"}' + (tmp_path / 'package.json').write_text(content) + doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json')) + + with ( + patch(f'{_BUN_MODULE}.shell', return_value=None), + patch.object(restore_bun.__class__.__bases__[0], 'try_restore_dependencies') as mock_super, + ): + result = restore_bun.try_restore_dependencies(doc) + assert result is None + mock_super.assert_not_called() + + def test_existing_lockfile_skips_version_check(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'bun.lock').write_text('{"lockfileVersion": 1}\n') + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + + with patch(f'{_BUN_MODULE}.shell') as mock_shell: + result = restore_bun.try_restore_dependencies(doc) + assert result is not None + mock_shell.assert_not_called() + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_bun: RestoreBunDependencies, tmp_path: Path + ) -> None: + # bun: no pre-existing bun.lock but package.json indicates bun (supported version installed) + content = '{"name": "test", "packageManager": "bun@1.2.0"}' + (tmp_path / 'package.json').write_text(content) + doc = Document(str(tmp_path / 'package.json'), content, absolute_path=str(tmp_path / 'package.json')) + lock_path = tmp_path / BUN_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('{"lockfileVersion": 1}\n') + return 'output' + + with ( + patch(f'{_BUN_MODULE}.shell', return_value='1.2.5'), + patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect), + ): + result = restore_bun.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{BUN_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_bun: RestoreBunDependencies, tmp_path: Path) -> None: + lock_content = '{"lockfileVersion": 1, "packages": {"pkg": ["pkg@1.0.0", "", {}, ""]}}\n' + (tmp_path / 'package.json').write_text('{"name": "test"}') + lock_path = tmp_path / BUN_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document(str(tmp_path / 'package.json'), '{"name": "test"}', absolute_path=str(tmp_path / 'package.json')) + + result = restore_bun.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {BUN_LOCK_FILE_NAME} must not be deleted' 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 c418b659..95f94da0 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 @@ -49,6 +49,15 @@ def test_package_json_with_pnpm_lock_does_not_match( 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_bun_lock_does_not_match( + self, restore_npm: RestoreNpmDependencies, tmp_path: Path + ) -> None: + """Bun projects are handled by RestoreBunDependencies — NPM should not claim them.""" + (tmp_path / 'package.json').write_text('{"name": "test"}') + (tmp_path / 'bun.lock').write_text('{"lockfileVersion": 1}\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_tsconfig_json_does_not_match(self, restore_npm: RestoreNpmDependencies) -> None: doc = Document('tsconfig.json', '{}') assert restore_npm.is_project(doc) is False From 7f44bc4b0651e4984a4c605d9e19c20e33c7f955 Mon Sep 17 00:00:00 2001 From: omer-roth Date: Sun, 5 Jul 2026 11:52:12 +0300 Subject: [PATCH 313/347] CM-68136 added package cooldown (#483) --- .github/dependabot.yml | 4 ++++ poetry.toml | 2 ++ 2 files changed, 6 insertions(+) create mode 100644 poetry.toml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0b845d3b..e46640bc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,8 +4,12 @@ updates: directory: "/" schedule: interval: "monthly" + cooldown: + default-days: 10 - package-ecosystem: "pip" directory: "/" schedule: interval: "monthly" + cooldown: + default-days: 10 diff --git a/poetry.toml b/poetry.toml new file mode 100644 index 00000000..9f0eee61 --- /dev/null +++ b/poetry.toml @@ -0,0 +1,2 @@ +[solver] +min-release-age = 7 # Requires Poetry >= 2.4.0; silently ignored by older versions From ac3b09e4d6fd128e259617194c9fd6a7a18a8034 Mon Sep 17 00:00:00 2001 From: omer-roth Date: Mon, 6 Jul 2026 11:10:22 +0300 Subject: [PATCH 314/347] CM-68185 update workflow cache settings (#489) --- .github/workflows/build_executable.yml | 3 ++- CODEOWNERS | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index d3ebb83e..31127510 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -62,6 +62,7 @@ jobs: echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV - name: Set up Python 3.13 + id: setup-python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.13' @@ -71,7 +72,7 @@ jobs: uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 with: path: ~/.local - key: poetry-${{ matrix.os }}-2 # increment to reset cache + key: poetry-${{ matrix.os }}-${{ steps.setup-python.outputs.python-version }}-2 # increment to reset cache - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' diff --git a/CODEOWNERS b/CODEOWNERS index e59df91b..9a3abb16 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1 +1 @@ -* @avishaiamiel @omerr-cycode +* @avishaiamiel @omer-roth From a11ea08e4280fc591fa539514bd363ca8a2eaea7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:07:41 +0000 Subject: [PATCH 315/347] Bump ruff from 0.15.15 to 0.15.20 (#487) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 40 ++++++++++++++++++++-------------------- pyproject.toml | 2 +- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/poetry.lock b/poetry.lock index 0b87b802..cf6fa0dc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1683,30 +1683,30 @@ files = [ [[package]] name = "ruff" -version = "0.15.15" +version = "0.15.20" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" groups = ["dev"] files = [ - {file = "ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b"}, - {file = "ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e"}, - {file = "ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530"}, - {file = "ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4"}, - {file = "ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f"}, - {file = "ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622"}, - {file = "ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45"}, - {file = "ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627"}, - {file = "ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4"}, - {file = "ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c"}, - {file = "ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd"}, - {file = "ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f"}, - {file = "ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb"}, - {file = "ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a"}, - {file = "ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9"}, - {file = "ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4"}, - {file = "ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7"}, - {file = "ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6"}, + {file = "ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078"}, + {file = "ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b"}, + {file = "ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b"}, + {file = "ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487"}, + {file = "ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3"}, + {file = "ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053"}, + {file = "ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4"}, + {file = "ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460"}, + {file = "ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21"}, + {file = "ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415"}, + {file = "ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca"}, + {file = "ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566"}, ] [[package]] @@ -2018,4 +2018,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "a347b4566b5612c753acadaef737ad7ca594be3402e0118c94bb539ed2062b06" +content-hash = "6e4f71b3a516dae60c71976f1c83fcb826b2225a9ddde328e9f80434a002c08e" diff --git a/pyproject.toml b/pyproject.toml index bfb34e90..1a510ac5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,7 +66,7 @@ pyinstaller = {version=">=6.20.0,<7.0.0", python=">=3.9,<3.15"} dunamai = ">=1.26.1,<1.27.0" [tool.poetry.group.dev.dependencies] -ruff = "0.15.15" +ruff = "0.15.20" [tool.pytest.ini_options] log_cli = true From 4c6e81cadef2c99da9410154d23f943615bbd795 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:17:46 +0000 Subject: [PATCH 316/347] Bump actions/setup-python from 6.2.0 to 6.3.0 (#480) 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 31127510..dd579290 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -63,7 +63,7 @@ jobs: - name: Set up Python 3.13 id: setup-python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.13' diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 412e38b9..fe5ae20e 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -31,7 +31,7 @@ jobs: git checkout ${{ steps.latest_tag.outputs.LATEST_TAG }} - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.9' diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 0a36cea1..9f8b99f5 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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.9' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d21a8f8d..f9ac7cb2 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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.9' diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 2f0e1253..2eb5ecb5 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: 3.9 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 517f10d1..b5ac4874 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: '3.9' diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index f2cf8d9e..d3ccc364 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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ matrix.python-version }} From ce210dfe40c15362fe01bfb03e4ad3062bb8a737 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:25:10 +0000 Subject: [PATCH 317/347] Bump actions/cache from 5.0.5 to 6.1.0 (#479) 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 dd579290..41523a1a 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -69,7 +69,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.local key: poetry-${{ matrix.os }}-${{ steps.setup-python.outputs.python-version }}-2 # increment to reset cache diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index fe5ae20e..95f4f3a9 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -37,7 +37,7 @@ jobs: - name: Load cached Poetry setup id: cached_poetry - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 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 9f8b99f5..837fe0b8 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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f9ac7cb2..d2ddc2c7 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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 2eb5ecb5..6272e060 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -30,7 +30,7 @@ jobs: - name: Load cached Poetry setup id: cached-poetry - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.local key: poetry-ubuntu-1 # increment to reset cache diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b5ac4874..a4019536 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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 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 d3ccc364..4a6a1946 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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.local key: poetry-${{ matrix.os }}-${{ matrix.python-version }}-3 # increment to reset cache From 9e3da7108ebf2c9b684ae8af31e26d494f01b004 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:33:04 +0300 Subject: [PATCH 318/347] Bump docker/setup-qemu-action from 4.0.0 to 4.1.0 (#484) 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 95f4f3a9..a692d4e1 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -61,7 +61,7 @@ jobs: echo "CLI_VERSION=$(poetry version --short)" >> $GITHUB_OUTPUT - name: Set up QEMU - uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0 + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 From 6c8a96db15a72649a89b34e0f55948a5554dafe1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:35:46 +0000 Subject: [PATCH 319/347] Bump mcp from 1.27.2 to 1.28.1 (#486) 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, 18 insertions(+), 9 deletions(-) diff --git a/poetry.lock b/poetry.lock index cf6fa0dc..1616e71a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -837,15 +837,15 @@ tests = ["pytest", "simplejson"] [[package]] name = "mcp" -version = "1.27.2" +version = "1.28.1" description = "Model Context Protocol SDK" optional = false python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "mcp-1.27.2-py3-none-any.whl", hash = "sha256:d6ff5160c6ca65d93013626efb3fc249de683c30b2d8570755ceddd490344de5"}, - {file = "mcp-1.27.2.tar.gz", hash = "sha256:8e02db104096d1c25b28e64bde29a5c32b31bc241710213e12fd4d84985bdfef"}, + {file = "mcp-1.28.1-py3-none-any.whl", hash = "sha256:2726bca5e7193f61c5dde8b12500a6de2d9acf6d1a1c0be9e8c2e706437991df"}, + {file = "mcp-1.28.1.tar.gz", hash = "sha256:d51e36a5f5644faea4f85ea649bfffa6bc6c26770d42798ad6a3de3d2ba69683"}, ] [package.dependencies] @@ -853,13 +853,22 @@ anyio = ">=4.5" httpx = ">=0.27.1,<1.0.0" httpx-sse = ">=0.4" jsonschema = ">=4.20.0" -pydantic = ">=2.11.0,<3.0.0" +pydantic = [ + {version = ">=2.11.0,<3.0.0", markers = "python_version < \"3.14\""}, + {version = ">=2.12.0,<3.0.0", markers = "python_version >= \"3.14\""}, +] pydantic-settings = ">=2.5.2" pyjwt = {version = ">=2.10.1", extras = ["crypto"]} python-multipart = ">=0.0.9" -pywin32 = {version = ">=310", markers = "sys_platform == \"win32\""} +pywin32 = [ + {version = ">=310", markers = "sys_platform == \"win32\" and python_version < \"3.14\""}, + {version = ">=311", markers = "sys_platform == \"win32\" and python_version >= \"3.14\""}, +] sse-starlette = ">=1.6.1" -starlette = ">=0.27" +starlette = [ + {version = ">=0.27", markers = "python_version < \"3.14\""}, + {version = ">=0.48.0", markers = "python_version >= \"3.14\""}, +] typing-extensions = ">=4.9.0" typing-inspection = ">=0.4.1" uvicorn = {version = ">=0.31.1", markers = "sys_platform != \"emscripten\""} @@ -1355,7 +1364,7 @@ description = "Python for Window Extensions" optional = false python-versions = "*" groups = ["main"] -markers = "python_version >= \"3.10\" and sys_platform == \"win32\"" +markers = "sys_platform == \"win32\" and python_version >= \"3.10\"" files = [ {file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"}, {file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"}, @@ -2018,4 +2027,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "6e4f71b3a516dae60c71976f1c83fcb826b2225a9ddde328e9f80434a002c08e" +content-hash = "6645eb474429e1c04c9cfc2225af5c227380c601c2b4ed1e309ff34bade63dd8" diff --git a/pyproject.toml b/pyproject.toml index 1a510ac5..afb4ddc3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ rich = ">=13.9.4, <14" patch-ng = "1.19.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'" } +mcp = { version = ">=1.28.1,<2.0.0", markers = "python_version >= '3.10'" } pydantic = ">=2.11.5,<3.0.0" pathvalidate = ">=3.3.1,<4.0.0" tomli-w = ">=1.0.0,<2.0.0" From a4b4957c9150528110dd0700055cd799e7fc37b4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:39:14 +0300 Subject: [PATCH 320/347] Bump mock from 4.0.3 to 5.2.0 (#485) 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 1616e71a..3a39aaa5 100644 --- a/poetry.lock +++ b/poetry.lock @@ -892,20 +892,20 @@ files = [ [[package]] name = "mock" -version = "4.0.3" +version = "5.2.0" 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"}, + {file = "mock-5.2.0-py3-none-any.whl", hash = "sha256:7ba87f72ca0e915175596069dbbcc7c75af7b5e9b9bc107ad6349ede0819982f"}, + {file = "mock-5.2.0.tar.gz", hash = "sha256:4e460e818629b4b173f32d08bf30d3af8123afbb8e04bb5707a1fd4799e503f0"}, ] [package.extras] build = ["blurb", "twine", "wheel"] docs = ["sphinx"] -test = ["pytest (<5.4)", "pytest-cov"] +test = ["pytest", "pytest-cov"] [[package]] name = "packaging" @@ -2027,4 +2027,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "6645eb474429e1c04c9cfc2225af5c227380c601c2b4ed1e309ff34bade63dd8" +content-hash = "09f70b525d7ba0c84e1a209e4ec1359d52a5e7901869c2b738abf5236f812594" diff --git a/pyproject.toml b/pyproject.toml index afb4ddc3..57e59c30 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,7 +54,7 @@ tomli = {version = ">=2.0.0,<3.0.0", python = "<3.11"} anyio = ">=4.0.0, <4.13.0" [tool.poetry.group.test.dependencies] -mock = ">=4.0.3,<4.1.0" +mock = ">=5.2.0,<5.3.0" pytest = ">=7.3.1,<8.5.0" pytest-mock = ">=3.10.0,<3.11.0" coverage = ">=7.2.3,<7.11.0" From c7dcb13eb59f9dd577f37f41471f85265e5d772e Mon Sep 17 00:00:00 2001 From: Ilia Shkolyar <60312091+ilia-cy@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:44:36 +0300 Subject: [PATCH 321/347] CM-67391: Use S3 presigned upload for secret CLI scans (#476) Co-authored-by: Claude Opus 4.8 (1M context) --- cycode/cli/apps/scan/code_scanner.py | 11 ++- cycode/cli/apps/scan/commit_range_scanner.py | 7 +- cycode/cli/consts.py | 3 +- tests/cli/commands/scan/test_code_scanner.py | 77 ++++++++++++++++++- .../scan/test_commit_range_scanner.py | 49 ++++++++++++ .../cyclient/mocked_responses/scan_client.py | 48 +++++++++++- 6 files changed, 187 insertions(+), 8 deletions(-) create mode 100644 tests/cli/commands/scan/test_commit_range_scanner.py diff --git a/cycode/cli/apps/scan/code_scanner.py b/cycode/cli/apps/scan/code_scanner.py index dc3727e4..667138fa 100644 --- a/cycode/cli/apps/scan/code_scanner.py +++ b/cycode/cli/apps/scan/code_scanner.py @@ -279,7 +279,10 @@ def scan_documents( scan_batch_thread_func = _get_scan_documents_thread_func(ctx, is_git_diff, is_commit_range, scan_parameters) - if should_use_presigned_upload(scan_type): + # Presigned single-file upload is async-only; a --sync scan must stay on the batched inline path + # so it never builds one oversized zip to POST synchronously. + should_use_sync_flow = _should_use_sync_flow(ctx.info_name, scan_type, ctx.obj['sync']) + if should_use_presigned_upload(scan_type) and not should_use_sync_flow: errors, local_scan_results = _run_presigned_upload_scan( scan_batch_thread_func, scan_type, documents_to_scan, progress_bar, printer ) @@ -388,7 +391,11 @@ def _perform_scan( is_commit_range, on_upload_progress, ) - except requests.exceptions.RequestException: + except ( + requests.exceptions.RequestException, + custom_exceptions.RequestError, + custom_exceptions.SlowUploadConnectionError, + ): logger.warning('Direct upload to object storage failed. Falling back to upload via Cycode API. ') return _perform_scan_async( diff --git a/cycode/cli/apps/scan/commit_range_scanner.py b/cycode/cli/apps/scan/commit_range_scanner.py index 9691be6e..298af34c 100644 --- a/cycode/cli/apps/scan/commit_range_scanner.py +++ b/cycode/cli/apps/scan/commit_range_scanner.py @@ -19,6 +19,7 @@ print_local_scan_results, ) 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.commit_range_documents import ( collect_commit_range_diff_documents, @@ -162,7 +163,11 @@ def _scan_commit_range_documents( scan_parameters, timeout, ) - except requests.exceptions.RequestException: + except ( + requests.exceptions.RequestException, + custom_exceptions.RequestError, + custom_exceptions.SlowUploadConnectionError, + ): logger.warning('Direct upload to object storage failed. Falling back to upload via Cycode API. ') scan_result = _perform_commit_range_scan_async( cycode_client, diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index a134f3f4..37ef2298 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -222,12 +222,13 @@ 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} +PRESIGNED_UPLOAD_SCAN_TYPES = {SAST_SCAN_TYPE, SECRET_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: PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES, + SECRET_SCAN_TYPE: PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES, } # scan in batches diff --git a/tests/cli/commands/scan/test_code_scanner.py b/tests/cli/commands/scan/test_code_scanner.py index 8a9f60b7..8b4a30b3 100644 --- a/tests/cli/commands/scan/test_code_scanner.py +++ b/tests/cli/commands/scan/test_code_scanner.py @@ -2,8 +2,11 @@ from os.path import normpath from unittest.mock import MagicMock, Mock, patch +import pytest + from cycode.cli import consts -from cycode.cli.apps.scan.code_scanner import scan_disk_files +from cycode.cli.apps.scan.code_scanner import _perform_scan, scan_disk_files, scan_documents +from cycode.cli.exceptions import custom_exceptions 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 @@ -162,3 +165,75 @@ def test_entrypoint_cycode_not_added_for_single_file( assert len(entrypoint_docs) == 0 # Verify only the original documents are present assert len(documents_passed) == len(mock_documents) + + +@pytest.mark.parametrize( + ('scan_type', 'command_scan_type', 'sync_option', 'expect_presigned'), + [ + # SAST keeps uploading directly to S3 via a presigned URL (regression guard for the new sync gate). + (consts.SAST_SCAN_TYPE, 'path', False, True), + # Async secret scans now upload as a single file directly to S3 via a presigned URL. + (consts.SECRET_SCAN_TYPE, 'path', False, True), + # A --sync secret scan must stay on the batched inline path and never build one giant zip. + (consts.SECRET_SCAN_TYPE, 'path', True, False), + ], +) +@patch('cycode.cli.apps.scan.code_scanner.print_local_scan_results') +@patch('cycode.cli.apps.scan.code_scanner.set_issue_detected_by_scan_results') +@patch('cycode.cli.apps.scan.code_scanner.try_set_aggregation_report_url_if_needed') +@patch('cycode.cli.apps.scan.code_scanner.run_parallel_batched_scan') +@patch('cycode.cli.apps.scan.code_scanner._run_presigned_upload_scan') +def test_scan_documents_routes_upload_by_scan_type_and_sync( + mock_presigned_upload: Mock, + mock_batched_scan: Mock, + mock_aggregation: Mock, + mock_set_issue: Mock, + mock_print: Mock, + scan_type: str, + command_scan_type: str, + sync_option: bool, + expect_presigned: bool, +) -> None: + mock_presigned_upload.return_value = ([], []) + mock_batched_scan.return_value = ([], []) + + mock_ctx = MagicMock() + mock_ctx.info_name = command_scan_type + mock_ctx.obj = { + 'scan_type': scan_type, + 'progress_bar': MagicMock(), + 'console_printer': MagicMock(), + 'client': MagicMock(), + 'severity_threshold': None, + 'sync': sync_option, + } + documents = [Document('/repo/file.py', 'content', is_git_diff_format=False)] + + scan_documents(mock_ctx, documents, {}) + + assert mock_presigned_upload.called is expect_presigned + assert mock_batched_scan.called is (not expect_presigned) + + +@patch('cycode.cli.apps.scan.code_scanner._perform_scan_async') +@patch('cycode.cli.apps.scan.code_scanner._perform_scan_v4_async') +def test_perform_scan_falls_back_to_api_when_presigned_upload_raises_wrapped_error( + mock_v4_async: Mock, mock_async: Mock +) -> None: + # RequestConnectionError is a CycodeError, not a requests.RequestException — the fallback must still catch it. + mock_v4_async.side_effect = custom_exceptions.RequestConnectionError + fallback_result = object() + mock_async.return_value = fallback_result + + result = _perform_scan( + cycode_client=MagicMock(), + zipped_documents=MagicMock(), + scan_type=consts.SAST_SCAN_TYPE, + is_git_diff=False, + is_commit_range=False, + scan_parameters={}, + ) + + assert result is fallback_result + mock_v4_async.assert_called_once() + mock_async.assert_called_once() diff --git a/tests/cli/commands/scan/test_commit_range_scanner.py b/tests/cli/commands/scan/test_commit_range_scanner.py new file mode 100644 index 00000000..a4a6c58b --- /dev/null +++ b/tests/cli/commands/scan/test_commit_range_scanner.py @@ -0,0 +1,49 @@ +from unittest.mock import MagicMock, Mock, patch + +from cycode.cli import consts +from cycode.cli.apps.scan.commit_range_scanner import _scan_commit_range_documents +from cycode.cli.exceptions import custom_exceptions +from cycode.cli.models import Document + + +@patch('cycode.cli.apps.scan.commit_range_scanner.report_scan_status') +@patch('cycode.cli.apps.scan.commit_range_scanner.handle_scan_exception') +@patch('cycode.cli.apps.scan.commit_range_scanner.print_local_scan_results') +@patch('cycode.cli.apps.scan.commit_range_scanner.set_issue_detected_by_scan_results') +@patch('cycode.cli.apps.scan.commit_range_scanner.create_local_scan_result') +@patch('cycode.cli.apps.scan.commit_range_scanner.enrich_scan_result_with_data_from_detection_rules') +@patch('cycode.cli.apps.scan.commit_range_scanner.zip_documents') +@patch('cycode.cli.apps.scan.commit_range_scanner._perform_commit_range_scan_async') +@patch('cycode.cli.apps.scan.commit_range_scanner._perform_commit_range_scan_v4_async') +def test_commit_range_scan_falls_back_to_api_when_presigned_upload_raises_wrapped_error( + mock_v4_async: Mock, + mock_async: Mock, + mock_zip: Mock, + mock_enrich: Mock, + mock_create_result: Mock, + mock_set_issue: Mock, + mock_print: Mock, + mock_handle_exception: Mock, + mock_report_status: Mock, +) -> None: + # SlowUploadConnectionError is a CycodeError, not a requests.RequestException — the presigned + # commit-range fallback must still catch it and retry via the Cycode API. + mock_v4_async.side_effect = custom_exceptions.SlowUploadConnectionError + fallback_result = MagicMock() + mock_async.return_value = fallback_result + + mock_ctx = MagicMock() + mock_ctx.info_name = 'commit_history' + mock_ctx.obj = { + 'client': MagicMock(), + 'scan_type': consts.SECRET_SCAN_TYPE, + 'severity_threshold': None, + 'progress_bar': MagicMock(), + } + documents = [Document('/repo/file.py', 'content', is_git_diff_format=False)] + + _scan_commit_range_documents(mock_ctx, documents, []) + + mock_v4_async.assert_called_once() + mock_async.assert_called_once() + mock_handle_exception.assert_not_called() diff --git a/tests/cyclient/mocked_responses/scan_client.py b/tests/cyclient/mocked_responses/scan_client.py index c37c1d8a..b2be7ab5 100644 --- a/tests/cyclient/mocked_responses/scan_client.py +++ b/tests/cyclient/mocked_responses/scan_client.py @@ -5,6 +5,7 @@ import responses +from cycode.cli.utils.scan_utils import should_use_presigned_upload from cycode.cyclient.scan_client import ScanClient from tests.conftest import MOCKED_RESPONSES_PATH @@ -128,6 +129,38 @@ def get_scan_configuration_response(url: str) -> responses.Response: return responses.Response(method=responses.GET, url=url, json=json_response, status=200) +_PRESIGNED_UPLOAD_URL = 'https://cycode-tests.s3.amazonaws.com/presigned-upload' + + +def get_upload_link_url(scan_type: str, scan_client: ScanClient) -> str: + api_url = scan_client.scan_cycode_client.api_url + async_scan_type = scan_client.scan_config.get_async_scan_type(scan_type) + service_url = f'{scan_client.get_scan_service_v4_url_path(scan_type)}/{async_scan_type}/upload-link' + return f'{api_url}/{service_url}' + + +def get_upload_link_response(url: str) -> responses.Response: + json_response = {'upload_id': str(uuid4()), 'url': _PRESIGNED_UPLOAD_URL, 'presigned_post_fields': {}} + return responses.Response(method=responses.GET, url=url, json=json_response, status=200) + + +def get_presigned_upload_response() -> responses.Response: + return responses.Response(method=responses.POST, url=_PRESIGNED_UPLOAD_URL, status=204) + + +def get_scan_from_upload_id_url(scan_type: str, scan_client: ScanClient) -> str: + api_url = scan_client.scan_cycode_client.api_url + async_scan_type = scan_client.scan_config.get_async_scan_type(scan_type) + service_url = f'{scan_client.get_scan_service_v4_url_path(scan_type)}/{async_scan_type}/repository' + return f'{api_url}/{service_url}' + + +def get_scan_from_upload_id_response(url: str, scan_id: Optional[UUID] = None) -> responses.Response: + if not scan_id: + scan_id = uuid4() + return responses.Response(method=responses.POST, url=url, json={'scan_id': str(scan_id)}, 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))) @@ -136,9 +169,18 @@ 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) - ) + + if should_use_presigned_upload(scan_type): + responses_module.add(get_upload_link_response(get_upload_link_url(scan_type, scan_client))) + responses_module.add(get_presigned_upload_response()) + responses_module.add( + get_scan_from_upload_id_response(get_scan_from_upload_id_url(scan_type, scan_client), scan_id) + ) + else: + 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_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)) From 2ce15d4befcbcd16fe0388b036aa2d09ae3f2d46 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 08:50:09 +0300 Subject: [PATCH 322/347] Bump snok/install-poetry from 1.4.1 to 1.4.2 (#469) 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 41523a1a..a9e5eddc 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -76,7 +76,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2 with: version: 2.2.1 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index a692d4e1..1266dcbf 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -44,7 +44,7 @@ jobs: - name: Setup Poetry if: steps.cached_poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2 with: version: 2.2.1 diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 837fe0b8..2dee645b 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -46,7 +46,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2 with: version: 2.2.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2ddc2c7..1bc04888 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,7 +45,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2 with: version: 2.2.1 diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 6272e060..2c1cd8c6 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -37,7 +37,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2 with: version: 2.2.1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a4019536..a9f2bc1c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -39,7 +39,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2 with: version: 2.2.1 diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index 4a6a1946..b4eb3e48 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -54,7 +54,7 @@ jobs: - name: Setup Poetry if: steps.cached-poetry.outputs.cache-hit != 'true' - uses: snok/install-poetry@76e04a911780d5b312d89783f7b1cd627778900a # v1.4.1 + uses: snok/install-poetry@a783c322200f0519c7926aa6faa857c4e23e9263 # v1.4.2 with: version: 2.2.1 From 2615e0259676f4c6c456675d32a8ad17d7491204 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Tue, 7 Jul 2026 10:40:06 +0300 Subject: [PATCH 323/347] CM-68232: read a machine-wide AI Guardrails policy path (#490) Co-authored-by: Claude Opus 4.8 (1M context) --- cycode/cli/apps/ai_guardrails/scan/policy.py | 24 +++++++-- .../ai_guardrails/scan/test_policy.py | 53 +++++++++++++++++++ 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/scan/policy.py b/cycode/cli/apps/ai_guardrails/scan/policy.py index f40d77c0..96c45574 100644 --- a/cycode/cli/apps/ai_guardrails/scan/policy.py +++ b/cycode/cli/apps/ai_guardrails/scan/policy.py @@ -3,11 +3,14 @@ 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) +2. Machine-wide config (admin/MDM-provisioned; see get_machine_policy_path) +3. User-level config (~/.cycode/ai-guardrails.yaml) +4. Repo-level config (/.cycode/ai-guardrails.yaml) """ import json +import os +import sys from pathlib import Path from typing import Any, Optional @@ -16,6 +19,16 @@ from cycode.cli.apps.ai_guardrails.scan.consts import DEFAULT_POLICY, POLICY_FILE_NAME +def get_machine_policy_path() -> Path: + """Machine-wide (admin/MDM-provisioned) policy path, by platform.""" + if sys.platform == 'darwin': + return Path('/Library/Application Support/Cycode') / POLICY_FILE_NAME + if sys.platform == 'win32': + program_data = os.environ.get('PROGRAMDATA', 'C:\\ProgramData') + return Path(program_data) / 'Cycode' / POLICY_FILE_NAME + return Path('/etc/cycode') / POLICY_FILE_NAME + + def deep_merge(base: dict, override: dict) -> dict: """Deep merge two dictionaries, with override taking precedence.""" result = base.copy() @@ -61,7 +74,7 @@ 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 + Merge order: defaults <- machine <- user config <- repo config Args: workspace_root: Workspace root path for repo-level config lookup. @@ -69,6 +82,11 @@ def load_policy(workspace_root: Optional[str] = None) -> dict: # Start with defaults policy = load_defaults() + # Merge machine-wide config (admin/MDM-provisioned) - overrides defaults, below user/repo. + machine_config = load_yaml_file(get_machine_policy_path()) + if machine_config: + policy = deep_merge(policy, machine_config) + # Merge user-level config (if exists) user_policy_path = Path.home() / '.cycode' / POLICY_FILE_NAME user_config = load_yaml_file(user_policy_path) diff --git a/tests/cli/commands/ai_guardrails/scan/test_policy.py b/tests/cli/commands/ai_guardrails/scan/test_policy.py index bbe884b0..a378ad1c 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_policy.py +++ b/tests/cli/commands/ai_guardrails/scan/test_policy.py @@ -4,10 +4,12 @@ from typing import Optional from unittest.mock import MagicMock, patch +import pytest from pyfakefs.fake_filesystem import FakeFilesystem from cycode.cli.apps.ai_guardrails.scan.policy import ( deep_merge, + get_machine_policy_path, get_policy_value, load_defaults, load_policy, @@ -197,3 +199,54 @@ def test_load_policy_none_workspace_root(mock_load: MagicMock) -> None: # Should only load defaults (no repo config) assert 'mode' in policy + + +def test_get_machine_policy_path_per_os(monkeypatch: pytest.MonkeyPatch) -> None: + """Test the per-OS machine policy locations.""" + with patch('sys.platform', 'darwin'): + assert get_machine_policy_path() == Path('/Library/Application Support/Cycode') / 'ai-guardrails.yaml' + + with patch('sys.platform', 'linux'): + assert get_machine_policy_path() == Path('/etc/cycode') / 'ai-guardrails.yaml' + + with patch('sys.platform', 'win32'): + monkeypatch.setenv('PROGRAMDATA', 'C:\\ProgramData') + assert get_machine_policy_path() == Path('C:\\ProgramData') / 'Cycode' / 'ai-guardrails.yaml' + + +@patch('pathlib.Path.home') +@patch('cycode.cli.apps.ai_guardrails.scan.policy.get_machine_policy_path') +def test_load_policy_with_machine_config( + mock_machine_path: MagicMock, mock_home: MagicMock, fs: FakeFilesystem +) -> None: + """Test that the machine-wide config overrides defaults.""" + mock_home.return_value = Path('/home/testuser') + machine_path = Path('/machine/ai-guardrails.yaml') + mock_machine_path.return_value = machine_path + fs.create_file(str(machine_path), contents='mode: warn\n') + + policy = load_policy() + + # Machine config overrides the built-in default (block); other keys inherit from defaults. + assert policy['mode'] == 'warn' + assert policy['fail_open'] is True + + +@patch('pathlib.Path.home') +@patch('cycode.cli.apps.ai_guardrails.scan.policy.get_machine_policy_path') +def test_load_policy_precedence_defaults_machine_user_repo( + mock_machine_path: MagicMock, mock_home: MagicMock, fs: FakeFilesystem +) -> None: + """Test full precedence: defaults < machine < user < repo.""" + mock_home.return_value = Path('/home/testuser') + machine_path = Path('/machine/ai-guardrails.yaml') + mock_machine_path.return_value = machine_path + fs.create_file(str(machine_path), contents='mode: warn\nfail_open: false\n') + fs.create_file('/home/testuser/.cycode/ai-guardrails.yaml', contents='fail_open: true\n') + fs.create_file('/fake/repo/.cycode/ai-guardrails.yaml', contents='mode: block\n') + + policy = load_policy('/fake/repo') + + # repo overrides machine's mode; user overrides machine's fail_open. + assert policy['mode'] == 'block' + assert policy['fail_open'] is True From 23158f506dccfb9a30bac58830887b4aac7ff7a2 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:46:30 +0300 Subject: [PATCH 324/347] CM-68330: fix hook payload handling on Cursor for Windows (#491) Co-authored-by: Claude Opus 4.8 (1M context) --- .../apps/ai_guardrails/scan/scan_command.py | 8 ++--- cycode/cli/apps/ai_guardrails/scan/utils.py | 17 ++++++++++ .../ai_guardrails/session_start_command.py | 4 +-- .../ai_guardrails/scan/test_scan_command.py | 24 ++++++++++++++ .../commands/ai_guardrails/scan/test_utils.py | 31 +++++++++++++++++++ 5 files changed, 78 insertions(+), 6 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index bd31d33e..e6f8b977 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -7,7 +7,6 @@ ``HookDecision``); ``IDE.build_hook_response`` is the per-IDE translation step. """ -import sys from typing import Annotated, Optional, Union import click @@ -18,7 +17,7 @@ from cycode.cli.apps.ai_guardrails.scan.handlers import get_handler_for_event from cycode.cli.apps.ai_guardrails.scan.policy import load_policy 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.apps.ai_guardrails.scan.utils import output_json, read_stdin_text, 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.logger import get_logger @@ -91,7 +90,7 @@ def scan_command( """ ide_integration = get_ide(ide) - stdin_data = sys.stdin.read().strip() + stdin_data = read_stdin_text().strip() payload = safe_json_parse(stdin_data) if not payload: @@ -113,7 +112,8 @@ def scan_command( event_name = unified_payload.event_name logger.debug('Processing AI guardrails hook', extra={'event_name': event_name, 'ide': ide_integration.name}) - workspace_roots = payload.get('workspace_roots', ['.']) + # `or` (not a .get default) - Cursor sends workspace_roots=[] when no folder is open. + workspace_roots = payload.get('workspace_roots') or ['.'] policy = load_policy(workspace_roots[0]) try: diff --git a/cycode/cli/apps/ai_guardrails/scan/utils.py b/cycode/cli/apps/ai_guardrails/scan/utils.py index e14c1c02..6223c925 100644 --- a/cycode/cli/apps/ai_guardrails/scan/utils.py +++ b/cycode/cli/apps/ai_guardrails/scan/utils.py @@ -6,11 +6,28 @@ import json import os +import sys from pathlib import Path from cycode.cli.apps.ai_guardrails.scan.policy import get_policy_value +def read_stdin_text() -> str: + """Read the hook payload from stdin as UTF-8 text. + + Reads bytes and decodes with utf-8-sig: hook payloads are UTF-8 JSON, but on Windows + Python decodes piped stdin with the ANSI code page (mojibake for non-ASCII prompts), + and Cursor on Windows prefixes the payload with a UTF-8 BOM - the -sig codec strips it. + """ + buffer = getattr(sys.stdin, 'buffer', None) + if buffer is not None: + return buffer.read().decode('utf-8-sig', errors='replace') + # No .buffer (tests mocking sys.stdin with StringIO, exotic streams) - text-mode fallback. + # lstrip the BOM here too: an already-decoded stream leaves it as U+FEFF, which json.loads + # rejects (and .strip() doesn't remove - it is not whitespace). + return sys.stdin.read().lstrip('\ufeff') + + def safe_json_parse(s: str) -> dict: """Parse JSON string, returning empty dict on failure.""" try: diff --git a/cycode/cli/apps/ai_guardrails/session_start_command.py b/cycode/cli/apps/ai_guardrails/session_start_command.py index 5d491f10..c7164c79 100644 --- a/cycode/cli/apps/ai_guardrails/session_start_command.py +++ b/cycode/cli/apps/ai_guardrails/session_start_command.py @@ -7,7 +7,7 @@ from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, get_ide from cycode.cli.apps.ai_guardrails.ides.base import IDE -from cycode.cli.apps.ai_guardrails.scan.utils import safe_json_parse +from cycode.cli.apps.ai_guardrails.scan.utils import read_stdin_text, safe_json_parse from cycode.cli.apps.auth.auth_common import get_authorization_info from cycode.cli.apps.auth.auth_manager import AuthManager from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception @@ -78,7 +78,7 @@ def session_start_command( logger.debug('No stdin payload (TTY), skipping session initialization') return - stdin_data = sys.stdin.read().strip() + stdin_data = read_stdin_text().strip() payload = safe_json_parse(stdin_data) if not payload: logger.debug('Empty or invalid stdin payload, skipping session initialization') 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 35f7e4fa..b7d7734a 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py +++ b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py @@ -141,6 +141,30 @@ def test_claude_code_payload_with_claude_code_ide( mock_scan_command_deps['get_handler'].assert_called_once() mock_handler.assert_called_once() + def test_empty_workspace_roots_falls_back_to_cwd( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Cursor sends workspace_roots=[] when no folder is open - must not crash.""" + payload = { + 'hook_event_name': 'beforeSubmitPrompt', + 'conversation_id': 'conv-123', + 'prompt': 'test', + 'workspace_roots': [], + } + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + + mock_scan_command_deps['load_policy'].return_value = {'fail_open': True} + mock_handler = MagicMock(return_value=HookDecision.allow(AiHookEventType.PROMPT)) + mock_scan_command_deps['get_handler'].return_value = mock_handler + + scan_command(mock_ctx, ide='cursor') + + mock_scan_command_deps['load_policy'].assert_called_once_with('.') + mock_handler.assert_called_once() + class TestDefaultIdeParameterViaCli: """Tests that verify default IDE parameter works correctly via CLI invocation.""" diff --git a/tests/cli/commands/ai_guardrails/scan/test_utils.py b/tests/cli/commands/ai_guardrails/scan/test_utils.py index ce84c609..46ae195d 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_utils.py +++ b/tests/cli/commands/ai_guardrails/scan/test_utils.py @@ -1,12 +1,43 @@ """Tests for AI guardrails utility functions.""" +import io +from unittest.mock import patch + from cycode.cli.apps.ai_guardrails.scan.utils import ( is_denied_path, matches_glob, normalize_path, + read_stdin_text, + safe_json_parse, ) +def test_read_stdin_text_decodes_bom_and_utf8() -> None: + """utf-8-sig byte decode strips the BOM Cursor sends on Windows and avoids ANSI mojibake.""" + raw = '\ufeff{"prompt": "café"}'.encode() # utf-8 with BOM, multi-byte non-ASCII content + fake_stdin = io.TextIOWrapper(io.BytesIO(raw), encoding='utf-8') + + with patch('sys.stdin', fake_stdin): + text = read_stdin_text() + + assert safe_json_parse(text)['prompt'] == 'café' + + +def test_read_stdin_text_falls_back_without_buffer() -> None: + """Streams without .buffer (e.g. StringIO in tests) fall back to a text-mode read, BOM-stripped.""" + with patch('sys.stdin', io.StringIO('{"a": 1}')): + assert read_stdin_text() == '{"a": 1}' + + with patch('sys.stdin', io.StringIO('\ufeff{"a": 1}')): + assert read_stdin_text() == '{"a": 1}' + + +def test_safe_json_parse_invalid_and_empty() -> None: + """Invalid JSON and empty inputs return an empty dict.""" + assert safe_json_parse('not valid json {') == {} + assert safe_json_parse('') == {} + + def test_normalize_path_rejects_escape() -> None: """Test that paths attempting to escape are rejected.""" path = '../../../etc/passwd' From 1342addaab9ae33c29f989aae0af3e6ab6922630 Mon Sep 17 00:00:00 2001 From: omer-roth Date: Thu, 9 Jul 2026 14:44:11 +0300 Subject: [PATCH 325/347] CM-68462 added support for gradlew (#492) --- .../sca/maven/restore_gradle_dependencies.py | 22 +++++-- .../maven/test_restore_gradle_dependencies.py | 58 +++++++++++++++++++ 2 files changed, 76 insertions(+), 4 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 d2687bf6..ea91a8de 100644 --- a/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py +++ b/cycode/cli/files_collector/sca/maven/restore_gradle_dependencies.py @@ -1,4 +1,5 @@ import os +import platform import re from typing import Optional @@ -12,19 +13,32 @@ 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_COMMAND = ['gradle', 'projects'] ALL_PROJECTS_REGEX = r"[+-]{3} Project '(.*?)'" +GRADLE_EXECUTABLE = 'gradle' +GRADLEW_FILE_NAME = 'gradlew' +GRADLEW_BAT_FILE_NAME = 'gradlew.bat' + class RestoreGradleDependencies(BaseRestoreDependencies): def __init__( 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) + self.gradle_executable = self._resolve_gradle_executable() if projects is None: projects = set() self.projects = self.get_all_projects() if self.is_gradle_sub_projects() else projects + def _resolve_gradle_executable(self) -> str: + scan_root = get_path_from_context(self.ctx) + if scan_root: + wrapper_name = GRADLEW_BAT_FILE_NAME if platform.system() == 'Windows' else GRADLEW_FILE_NAME + wrapper_path = os.path.join(scan_root, wrapper_name) + if os.path.isfile(wrapper_path): + return wrapper_path + return GRADLE_EXECUTABLE + def is_gradle_sub_projects(self) -> bool: return self.ctx.obj.get('gradle_all_sub_projects', False) @@ -35,7 +49,7 @@ 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() - else [['gradle', 'dependencies', '-b', manifest_file_path, '-q', '--console', 'plain']] + else [[self.gradle_executable, 'dependencies', '-b', manifest_file_path, '-q', '--console', 'plain']] ) def get_lock_file_name(self) -> str: @@ -49,7 +63,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, + command=[self.gradle_executable, 'projects'], timeout=self.command_timeout, working_directory=get_path_from_context(self.ctx), ) @@ -62,7 +76,7 @@ def get_commands_for_sub_projects(self, manifest_file_path: str) -> list[list[st 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']] + [[self.gradle_executable, f'{project_name}:dependencies', '-q', '--console', 'plain']] if project_name in self.projects else [] ) diff --git a/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py b/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py index 72ca8a7d..43d34e30 100644 --- a/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py +++ b/tests/cli/files_collector/sca/maven/test_restore_gradle_dependencies.py @@ -9,11 +9,15 @@ BUILD_GRADLE_DEP_TREE_FILE_NAME, BUILD_GRADLE_FILE_NAME, BUILD_GRADLE_KTS_FILE_NAME, + GRADLE_EXECUTABLE, + GRADLEW_BAT_FILE_NAME, + GRADLEW_FILE_NAME, RestoreGradleDependencies, ) from cycode.cli.models import Document _BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' +_GRADLE_MODULE = 'cycode.cli.files_collector.sca.maven.restore_gradle_dependencies' @pytest.fixture @@ -47,6 +51,60 @@ def test_settings_gradle_does_not_match(self, restore_gradle: RestoreGradleDepen assert restore_gradle.is_project(doc) is False +class TestResolveGradleExecutable: + def test_falls_back_to_gradle_when_no_wrapper(self, restore_gradle: RestoreGradleDependencies) -> None: + assert restore_gradle.gradle_executable == GRADLE_EXECUTABLE + + def test_prefers_gradlew_wrapper_on_posix(self, mock_ctx: typer.Context, tmp_path: Path) -> None: + wrapper = tmp_path / GRADLEW_FILE_NAME + wrapper.write_text('#!/bin/sh\n') + with patch(f'{_GRADLE_MODULE}.platform.system', return_value='Linux'): + restore = RestoreGradleDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + assert restore.gradle_executable == str(wrapper) + + def test_prefers_gradlew_bat_on_windows(self, mock_ctx: typer.Context, tmp_path: Path) -> None: + wrapper = tmp_path / GRADLEW_BAT_FILE_NAME + wrapper.write_text('@echo off\n') + with patch(f'{_GRADLE_MODULE}.platform.system', return_value='Windows'): + restore = RestoreGradleDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + assert restore.gradle_executable == str(wrapper) + + def test_posix_ignores_bat_wrapper(self, mock_ctx: typer.Context, tmp_path: Path) -> None: + (tmp_path / GRADLEW_BAT_FILE_NAME).write_text('@echo off\n') + with patch(f'{_GRADLE_MODULE}.platform.system', return_value='Linux'): + restore = RestoreGradleDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + assert restore.gradle_executable == GRADLE_EXECUTABLE + + def test_wrapper_is_threaded_into_get_commands(self, mock_ctx: typer.Context, tmp_path: Path) -> None: + wrapper = tmp_path / GRADLEW_FILE_NAME + wrapper.write_text('#!/bin/sh\n') + with patch(f'{_GRADLE_MODULE}.platform.system', return_value='Linux'): + restore = RestoreGradleDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + commands = restore.get_commands(str(tmp_path / BUILD_GRADLE_FILE_NAME)) + assert commands == [ + [str(wrapper), 'dependencies', '-b', str(tmp_path / BUILD_GRADLE_FILE_NAME), '-q', '--console', 'plain'] + ] + + def test_wrapper_is_threaded_into_sub_project_commands(self, tmp_path: Path) -> None: + wrapper = tmp_path / GRADLEW_FILE_NAME + wrapper.write_text('#!/bin/sh\n') + ctx = MagicMock(spec=typer.Context) + ctx.obj = {'monitor': False, 'gradle_all_sub_projects': True} + ctx.params = {'path': str(tmp_path)} + module_project = tmp_path / 'module-a' + module_project.mkdir() + manifest = module_project / BUILD_GRADLE_FILE_NAME + + with ( + patch(f'{_GRADLE_MODULE}.platform.system', return_value='Linux'), + patch.object(RestoreGradleDependencies, 'get_all_projects', return_value={':module-a'}), + ): + restore = RestoreGradleDependencies(ctx, is_git_diff=False, command_timeout=30) + + commands = restore.get_commands_for_sub_projects(str(manifest)) + assert commands == [[str(wrapper), ':module-a:dependencies', '-q', '--console', 'plain']] + + class TestCleanup: def test_generated_dep_tree_file_is_deleted_after_restore( self, restore_gradle: RestoreGradleDependencies, tmp_path: Path From 49c9e404e247c3f5a00388a80800809ae0cf3736 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:32:56 +0300 Subject: [PATCH 326/347] CM-68342: sweep all IDEs' session context and dedup reports (#493) Co-authored-by: Claude Fable 5 --- .../cli/apps/ai_guardrails/ides/__init__.py | 19 ++ .../apps/ai_guardrails/ides/_plugin_utils.py | 16 + .../apps/ai_guardrails/ides/claude_code.py | 26 +- cycode/cli/apps/ai_guardrails/ides/codex.py | 17 +- .../ai_guardrails/session_start_command.py | 101 ++++-- cycode/cyclient/ai_security_manager_client.py | 10 +- .../ai_guardrails/ides/test_claude_code.py | 55 +++- .../commands/ai_guardrails/ides/test_codex.py | 5 +- .../test_session_start_command.py | 304 +++++++++++++----- 9 files changed, 419 insertions(+), 134 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/ides/__init__.py b/cycode/cli/apps/ai_guardrails/ides/__init__.py index e598c5a5..127431ef 100644 --- a/cycode/cli/apps/ai_guardrails/ides/__init__.py +++ b/cycode/cli/apps/ai_guardrails/ides/__init__.py @@ -34,6 +34,25 @@ def get_ide(name: str) -> IDE: return ide +def collect_all_session_contexts() -> tuple[dict[str, dict], dict]: + """Sweep every registered IDE's session context, regardless of which IDE triggered the hook. + + Returns ``(config_files_by_ide, plugins)``: the global MCP config file of each IDE that has + one (keyed by IDE name), and the enabled plugins merged across IDEs (first registered IDE + wins on a duplicate plugin key - plugins are IDE-agnostic marketplace artifacts). + """ + config_files_by_ide: dict[str, dict] = {} + plugins: dict = {} + for ide in IDES.values(): + global_config_file, enabled_plugins = ide.get_session_context() + if global_config_file: + config_files_by_ide[ide.name] = global_config_file + for plugin_key, plugin in (enabled_plugins or {}).items(): + plugins.setdefault(plugin_key, plugin) + + return config_files_by_ide, plugins + + def resolve_ides(name: str) -> list[IDE]: """Resolve an ``--ide`` argument to one or all IDE instances. diff --git a/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py b/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py index 124fdc99..6f7d8917 100644 --- a/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py +++ b/cycode/cli/apps/ai_guardrails/ides/_plugin_utils.py @@ -15,6 +15,22 @@ logger = get_logger('AI Guardrails Plugins') +def resolve_cached_plugin_dir(cache_root: Path, marketplace: str, plugin_name: str) -> Optional[Path]: + """Find ``////``. + + Both Claude Code and Codex cache installed plugin content in this layout (the trailing + segment is a version for Claude, a content hash for Codex). If multiple are cached, pick + the most recently modified (name as a deterministic tie-breaker). + """ + base = cache_root / marketplace / plugin_name + if not base.is_dir(): + return None + candidates = [d for d in base.iterdir() if d.is_dir()] + if not candidates: + return None + return max(candidates, key=lambda d: (d.stat().st_mtime, d.name)) + + def load_plugin_json(path: Path) -> Optional[dict]: """Load a JSON file inside a plugin directory; None if missing or invalid.""" if not path.exists(): diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py index 06989d87..17e7563d 100644 --- a/cycode/cli/apps/ai_guardrails/ides/claude_code.py +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -10,6 +10,7 @@ from cycode.cli.apps.ai_guardrails.ides._plugin_utils import ( build_global_config_file, load_plugin_json, + resolve_cached_plugin_dir, walk_enabled_plugins, ) from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision @@ -164,6 +165,11 @@ def load_claude_settings(settings_path: Optional[Path] = None) -> Optional[dict] return None +def _plugins_cache_dir() -> Path: + """Claude Code's local plugin content cache: ``~/.claude/plugins/cache////``.""" + return Path.home() / '.claude' / 'plugins' / 'cache' + + def _resolve_marketplace_path(marketplace: dict) -> Optional[Path]: """Resolve filesystem path for a directory-type marketplace.""" source = marketplace.get('source', {}) @@ -194,25 +200,29 @@ def _read_claude_plugin(plugin_dir: Path) -> tuple[dict, dict]: if servers: entry['mcp_server_names'] = list(servers.keys()) entry['mcp_config_file_path'] = str(mcp_config_path) - entry['mcp_config_file'] = json.dumps(mcp_config) + entry['mcp_config_file'] = json.dumps({'mcpServers': servers}) return entry, servers def resolve_plugins(settings: dict) -> dict: """Walk Claude Code's ``enabledPlugins`` via the shared plugin walker. - Each enabled plugin's marketplace is resolved through - ``extraKnownMarketplaces`` to a directory; the rest of the work - (manifest + ``.mcp.json``) is the shared ``_read_claude_plugin``. + Directory-type marketplaces resolve through ``extraKnownMarketplaces``; all + other source types (git, github, ...) resolve through the local plugin cache. + The rest of the work (manifest + ``.mcp.json``) is the shared ``_read_claude_plugin``. """ enabled = settings.get('enabledPlugins') or {} marketplaces = settings.get('extraKnownMarketplaces') or {} - def _locate(_plugin_name: str, marketplace_name: str) -> Optional[Path]: + def _locate(plugin_name: str, marketplace_name: str) -> Optional[Path]: + # Directory-type marketplaces point straight at the plugin source; every other source + # type (git, github, ...) is cloned into the local plugin cache. marketplace = marketplaces.get(marketplace_name) - if not marketplace: - return None - return _resolve_marketplace_path(marketplace) + if marketplace: + marketplace_path = _resolve_marketplace_path(marketplace) + if marketplace_path is not None: + return marketplace_path + return resolve_cached_plugin_dir(_plugins_cache_dir(), marketplace_name, plugin_name) return walk_enabled_plugins( plugin_entries=enabled, diff --git a/cycode/cli/apps/ai_guardrails/ides/codex.py b/cycode/cli/apps/ai_guardrails/ides/codex.py index 2bfd70dc..e8049621 100644 --- a/cycode/cli/apps/ai_guardrails/ides/codex.py +++ b/cycode/cli/apps/ai_guardrails/ides/codex.py @@ -17,6 +17,7 @@ from cycode.cli.apps.ai_guardrails.ides._plugin_utils import ( build_global_config_file, load_plugin_json, + resolve_cached_plugin_dir, walk_enabled_plugins, ) from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision @@ -100,18 +101,8 @@ def _email_from_auth(auth_path: Optional[Path] = None) -> Optional[str]: def _resolve_codex_plugin_dir(plugin_name: str, marketplace: str) -> Optional[Path]: - """Find ``~/.codex/plugins/cache////``. - - The trailing segment is a content hash. If multiple are cached, pick the - most recently modified. - """ - base = _codex_home() / 'plugins' / 'cache' / marketplace / plugin_name - if not base.is_dir(): - return None - candidates = [d for d in base.iterdir() if d.is_dir()] - if not candidates: - return None - return max(candidates, key=lambda d: d.stat().st_mtime) + """Find ``~/.codex/plugins/cache////``.""" + return resolve_cached_plugin_dir(_codex_home() / 'plugins' / 'cache', marketplace, plugin_name) def _read_codex_plugin(plugin_dir: Path) -> tuple[dict, dict]: @@ -141,7 +132,7 @@ def _read_codex_plugin(plugin_dir: Path) -> tuple[dict, dict]: if servers: entry['mcp_server_names'] = list(servers.keys()) entry['mcp_config_file_path'] = str(mcp_config_path) - entry['mcp_config_file'] = json.dumps(mcp_doc) + entry['mcp_config_file'] = json.dumps({'mcpServers': servers}) return entry, servers diff --git a/cycode/cli/apps/ai_guardrails/session_start_command.py b/cycode/cli/apps/ai_guardrails/session_start_command.py index c7164c79..bebd421d 100644 --- a/cycode/cli/apps/ai_guardrails/session_start_command.py +++ b/cycode/cli/apps/ai_guardrails/session_start_command.py @@ -1,12 +1,15 @@ """Handle AI guardrails session start: auth, conversation creation, session context.""" +import hashlib +import json import sys +import time +from pathlib import Path from typing import TYPE_CHECKING, Annotated, Optional import typer -from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, get_ide -from cycode.cli.apps.ai_guardrails.ides.base import IDE +from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, collect_all_session_contexts, get_ide from cycode.cli.apps.ai_guardrails.scan.utils import read_stdin_text, safe_json_parse from cycode.cli.apps.auth.auth_common import get_authorization_info from cycode.cli.apps.auth.auth_manager import AuthManager @@ -26,23 +29,76 @@ logger = get_logger('AI Guardrails') +_SESSION_CONTEXT_CACHE_FILE = '.session-context-cache' +_SESSION_CONTEXT_TTL_SECONDS = 7 * 24 * 60 * 60 -def _report_session_context(ai_client: 'AISecurityManagerClient', ide: IDE, user_email: Optional[str]) -> None: - """Report IDE session context to the AI security manager. Never raises.""" + +def _session_context_cache_path() -> Path: + return Path.home() / '.cycode' / _SESSION_CONTEXT_CACHE_FILE + + +def _session_context_digest(report: dict) -> str: + """Deterministic hash of the outgoing payload (not the raw config files, which churn).""" + canonical = json.dumps(report, sort_keys=True, separators=(',', ':'), default=str) + return hashlib.sha256(canonical.encode('utf-8')).hexdigest() + + +def _should_skip_report(digest: str, tenant_id: Optional[str]) -> bool: + """Skip when the same payload was already sent for this tenant and the TTL hasn't expired.""" try: - global_config_file, enabled_plugins = ide.get_session_context() - if not global_config_file and not enabled_plugins: - return - ai_client.report_session_context( - hostname=get_hostname(), - platform_name=get_platform_name(), - os_version=get_os_version(), - serial_number=get_serial_number(), - last_login_user=get_last_login_user(), - global_config_file=global_config_file, - enabled_plugins=enabled_plugins, - user_email=user_email, + cache = json.loads(_session_context_cache_path().read_text(encoding='utf-8')) + return ( + cache.get('hash') == digest + and cache.get('tenant_id') == tenant_id + and time.time() - float(cache.get('sent_at', 0)) < _SESSION_CONTEXT_TTL_SECONDS + ) + except Exception: + # Missing/corrupt cache reads as a miss - over-sending is harmless + return False + + +def _save_report_cache(digest: str, tenant_id: Optional[str]) -> None: + try: + cache_path = _session_context_cache_path() + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_text( + json.dumps({'hash': digest, 'tenant_id': tenant_id, 'sent_at': time.time()}), encoding='utf-8' ) + except Exception as e: + logger.debug('Failed to write session context cache', exc_info=e) + + +def _report_session_context( + ai_client: 'AISecurityManagerClient', + user_email: Optional[str], + tenant_id: Optional[str], +) -> None: + """Report the device + cross-IDE session context to the AI security manager. Never raises. + + The device context is always reported. MCP configs are collected from every registered IDE, + not just the triggering one. Unchanged payloads are skipped via a hash cache until the TTL expires. + """ + try: + config_files_by_ide, enabled_plugins = collect_all_session_contexts() + report = { + 'hostname': get_hostname(), + 'platform_name': get_platform_name(), + 'os_version': get_os_version(), + 'serial_number': get_serial_number(), + 'last_login_user': get_last_login_user(), + # Sorted by path so the digest is stable regardless of IDE registry order. + 'config_files': sorted(config_files_by_ide.values(), key=lambda f: f['path']), + 'enabled_plugins': enabled_plugins, + 'user_email': user_email, + } + + digest = _session_context_digest(report) + if _should_skip_report(digest, tenant_id): + logger.debug('Session context unchanged; skipping report') + return + + if ai_client.report_session_context(**report): + _save_report_cache(digest, tenant_id) except Exception as e: logger.debug('Failed to report session context', exc_info=e) @@ -61,7 +117,7 @@ def session_start_command( """Handle session start: ensure auth, create conversation, report session context.""" ide_integration = get_ide(ide) - # Step 1: Ensure authentication + # Ensure authentication auth_info = get_authorization_info(ctx) if auth_info is None: logger.debug('Not authenticated, starting authentication') @@ -70,10 +126,11 @@ def session_start_command( except Exception as err: handle_auth_exception(ctx, err) return + auth_info = get_authorization_info(ctx) else: logger.debug('Already authenticated') - # Step 2: Read stdin payload (backward compat: old hooks pipe no stdin) + # Read stdin payload (backward compat: old hooks pipe no stdin) if sys.stdin.isatty(): logger.debug('No stdin payload (TTY), skipping session initialization') return @@ -84,7 +141,7 @@ def session_start_command( logger.debug('Empty or invalid stdin payload, skipping session initialization') return - # Step 3: Build session payload + initialize API client + # Build session payload + initialize API client session_payload = ide_integration.build_session_payload(payload) try: @@ -93,11 +150,11 @@ def session_start_command( logger.debug('Failed to initialize AI security client', exc_info=e) return - # Step 4: Create conversation + # Create conversation try: ai_client.create_conversation(session_payload) except Exception as e: logger.debug('Failed to create conversation during session start', exc_info=e) - # Step 5: Report session context (MCP servers, enabled plugins) - _report_session_context(ai_client, ide_integration, session_payload.ide_user_email) + # Report session context (device + cross-IDE MCP servers and plugins) + _report_session_context(ai_client, session_payload.ide_user_email, auth_info.tenant_id) diff --git a/cycode/cyclient/ai_security_manager_client.py b/cycode/cyclient/ai_security_manager_client.py index a4f9bd76..5dee7f2c 100644 --- a/cycode/cyclient/ai_security_manager_client.py +++ b/cycode/cyclient/ai_security_manager_client.py @@ -98,11 +98,11 @@ def report_session_context( os_version: Optional[str] = None, serial_number: Optional[str] = None, last_login_user: Optional[str] = None, - global_config_file: Optional[dict] = None, + config_files: Optional[list[dict]] = None, enabled_plugins: Optional[dict] = None, user_email: Optional[str] = None, - ) -> None: - """Report session context to the backend.""" + ) -> bool: + """Report session context to the backend. Returns whether the report was accepted.""" body: dict = { 'hostname': hostname, 'platform_name': platform_name, @@ -110,12 +110,14 @@ def report_session_context( 'serial_number': serial_number, 'last_login_user': last_login_user, 'user_email': user_email, - 'global_config_file': global_config_file, + 'config_files': config_files, 'enabled_plugins': enabled_plugins, } try: self.client.post(self._build_endpoint_path(self._SESSION_CONTEXT_PATH), body=body) + return True except Exception as e: logger.debug('Failed to report session context', exc_info=e) # Don't fail the session if reporting fails + return False diff --git a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py index d1e28c0b..dbaca44f 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py +++ b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py @@ -13,6 +13,7 @@ _email_from_config, _read_claude_plugin, load_claude_config, + resolve_plugins, ) from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType @@ -222,34 +223,68 @@ def test_email_none_when_no_oauth(mocker: MockerFixture) -> None: # _read_claude_plugin -def test_read_claude_plugin_includes_mcp_config_file(tmp_path: Path) -> None: - mcp_content = {'mcpServers': {'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}}} - (tmp_path / '.mcp.json').write_text(json.dumps(mcp_content)) +def test_read_claude_plugin_includes_mcp_config_file(fs: FakeFilesystem) -> None: + plugin_dir = Path('/dummy/plugin') + mcp_content = {'mcpServers': {'dummy-server': {'command': 'dummy-command', 'args': ['serve']}}} + fs.create_file(plugin_dir / '.mcp.json', contents=json.dumps(mcp_content)) - entry, servers = _read_claude_plugin(tmp_path) + entry, servers = _read_claude_plugin(plugin_dir) assert 'mcp_config_file' in entry assert json.loads(entry['mcp_config_file']) == mcp_content - assert entry['mcp_config_file_path'] == str(tmp_path / '.mcp.json') + assert entry['mcp_config_file_path'] == str(plugin_dir / '.mcp.json') assert servers == mcp_content['mcpServers'] -def test_read_claude_plugin_no_mcp_config_file_when_no_servers(tmp_path: Path) -> None: - (tmp_path / '.mcp.json').write_text(json.dumps({'mcpServers': {}})) +def test_read_claude_plugin_no_mcp_config_file_when_no_servers(fs: FakeFilesystem) -> None: + plugin_dir = Path('/dummy/plugin') + fs.create_file(plugin_dir / '.mcp.json', contents=json.dumps({'mcpServers': {}})) - entry, servers = _read_claude_plugin(tmp_path) + entry, servers = _read_claude_plugin(plugin_dir) assert 'mcp_config_file' not in entry assert servers == {} -def test_read_claude_plugin_no_mcp_config_file_when_missing(tmp_path: Path) -> None: - entry, servers = _read_claude_plugin(tmp_path) +def test_read_claude_plugin_no_mcp_config_file_when_missing(fs: FakeFilesystem) -> None: + plugin_dir = Path('/dummy/plugin') + fs.create_dir(plugin_dir) + + entry, servers = _read_claude_plugin(plugin_dir) assert 'mcp_config_file' not in entry assert servers == {} +# resolve_plugins + + +def test_resolve_plugins_git_marketplace_resolves_from_cache(fs: FakeFilesystem) -> None: + """Non-directory marketplaces (git/github) resolve through ~/.claude/plugins/cache.""" + plugin_dir = Path.home() / '.claude' / 'plugins' / 'cache' / 'dummy-marketplace' / 'dummy-plugin' / '1.0.1' + fs.create_file( + plugin_dir / '.claude-plugin' / 'plugin.json', + contents=json.dumps({'name': 'dummy-plugin', 'version': '1.0.1'}), + ) + fs.create_file( + plugin_dir / '.mcp.json', + contents=json.dumps({'mcpServers': {'dummy-server': {'command': 'dummy-command'}}}), + ) + + settings = { + 'enabledPlugins': {'dummy-plugin@dummy-marketplace': True}, + 'extraKnownMarketplaces': { + 'dummy-marketplace': {'source': {'source': 'git', 'url': 'git@example.com:dummy/dummy-marketplace.git'}} + }, + } + plugins = resolve_plugins(settings) + + entry = plugins['dummy-plugin@dummy-marketplace'] + assert entry['version'] == '1.0.1' + assert entry['mcp_server_names'] == ['dummy-server'] + assert entry['mcp_config_file_path'] == str(plugin_dir / '.mcp.json') + + # Session context diff --git a/tests/cli/commands/ai_guardrails/ides/test_codex.py b/tests/cli/commands/ai_guardrails/ides/test_codex.py index 285b889c..682739e6 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_codex.py +++ b/tests/cli/commands/ai_guardrails/ides/test_codex.py @@ -358,13 +358,14 @@ def test_read_codex_plugin_includes_mcp_config_file(tmp_path: Path) -> None: def test_read_codex_plugin_mcp_config_file_bare_map(tmp_path: Path) -> None: - # Codex MCP files may be a bare {name: cfg} map with no mcpServers wrapper. + # Codex MCP files may be a bare {name: cfg} map with no mcpServers wrapper; the serialized + # session-context content is normalized to the canonical wrapped shape. mcp_content = {'dummy-server': {'command': 'dummy-command'}} _write_codex_plugin(tmp_path, mcp_content) entry, servers = _read_codex_plugin(tmp_path) - assert json.loads(entry['mcp_config_file']) == mcp_content + assert json.loads(entry['mcp_config_file']) == {'mcpServers': mcp_content} assert servers == mcp_content diff --git a/tests/cli/commands/ai_guardrails/test_session_start_command.py b/tests/cli/commands/ai_guardrails/test_session_start_command.py index ed6708ce..6beaa615 100644 --- a/tests/cli/commands/ai_guardrails/test_session_start_command.py +++ b/tests/cli/commands/ai_guardrails/test_session_start_command.py @@ -9,7 +9,9 @@ import typer from cycode.cli.apps.ai_guardrails import session_start_command as _session_start_mod +from cycode.cli.apps.ai_guardrails.ides import IDES, collect_all_session_contexts from cycode.cli.apps.ai_guardrails.ides import claude_code as _claude_mod +from cycode.cli.apps.ai_guardrails.ides import codex as _codex_mod from cycode.cli.apps.ai_guardrails.ides import cursor as _cursor_mod from cycode.cli.apps.ai_guardrails.session_start_command import session_start_command @@ -22,6 +24,12 @@ def mock_ctx() -> MagicMock: return ctx +@pytest.fixture(autouse=True) +def _isolated_session_context_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Keep the dedup cache away from the real ~/.cycode in every test.""" + monkeypatch.setattr(_session_start_mod, '_session_context_cache_path', lambda: tmp_path / '.session-context-cache') + + # Auth tests @@ -202,52 +210,83 @@ def test_conversation_creation_failure_non_blocking( # Should not raise -# MCP server reporting tests +# Session context reporting tests -@patch.object(_claude_mod, 'load_claude_settings') -@patch.object(_claude_mod, 'load_claude_config') +@patch.object(_claude_mod, 'load_claude_config', return_value={}) +@patch.object(_session_start_mod, 'collect_all_session_contexts') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') -def test_claude_code_reports_mcp_servers( +def test_reports_cross_ide_session_context( mock_get_auth: MagicMock, mock_get_client: MagicMock, + mock_collect: MagicMock, mock_load_config: MagicMock, - mock_load_settings: MagicMock, mock_ctx: MagicMock, ) -> None: - """Claude Code should report MCP servers from ~/.claude.json and enriched plugins.""" - mock_get_auth.return_value = MagicMock() + """All registered IDEs' configs go into config_files.""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') mock_ai_client = MagicMock() mock_get_client.return_value = mock_ai_client - mcp_servers = { - 'gitlab': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-gitlab']}, - 'filesystem': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-filesystem']}, - } - mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'test@test.com'}, 'mcpServers': mcp_servers} - # Marketplace won't resolve (no extraKnownMarketplaces) so plugin gets {"enabled": True} only. - mock_load_settings.return_value = {'enabledPlugins': {'cycode-dev@cycode-marketplace': True}} + cursor_file = {'path': '/home/u/.cursor/mcp.json', 'content': '{"mcpServers": {}}'} + claude_file = {'path': '/home/u/.claude.json', 'content': '{"mcpServers": {}}'} + plugins = {'dummy-plugin@dummy-marketplace': {'enabled': True}} + mock_collect.return_value = ({'cursor': cursor_file, 'claude-code': claude_file}, plugins) payload = {'session_id': 'session-123'} with patch('sys.stdin', new=StringIO(json.dumps(payload))): session_start_command(mock_ctx, ide='claude-code') + # config_files is sorted by path for a stable digest. mock_ai_client.report_session_context.assert_called_once_with( hostname=ANY, platform_name=ANY, os_version=ANY, serial_number=ANY, last_login_user=ANY, - global_config_file={ - 'path': str(_claude_mod._CLAUDE_CONFIG_PATH), - 'content': json.dumps({'mcpServers': mcp_servers}), - }, - enabled_plugins={'cycode-dev@cycode-marketplace': {'enabled': True}}, - user_email='test@test.com', + config_files=[claude_file, cursor_file], + enabled_plugins=plugins, + user_email=None, ) +@patch.object(_claude_mod, 'load_claude_config', return_value={}) +@patch.object(_session_start_mod, 'collect_all_session_contexts') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_no_mcp_anywhere_still_reports_device( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_collect: MagicMock, + mock_load_config: MagicMock, + mock_ctx: MagicMock, +) -> None: + """A machine with no MCP configs or plugins must still report its device context.""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_collect.return_value = ({}, {}) + + payload = {'session_id': 'session-123'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='claude-code') + + mock_ai_client.report_session_context.assert_called_once_with( + hostname=ANY, + platform_name=ANY, + os_version=ANY, + serial_number=ANY, + last_login_user=ANY, + config_files=[], + enabled_plugins={}, + user_email=None, + ) + + +@patch.object(_codex_mod, '_load_codex_config') +@patch.object(_cursor_mod, '_load_cursor_mcp_config') @patch.object(_claude_mod, 'load_claude_settings') @patch.object(_claude_mod, 'load_claude_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @@ -257,32 +296,36 @@ def test_claude_code_reports_global_file_and_plugin_metadata( mock_get_client: MagicMock, mock_load_config: MagicMock, mock_load_settings: MagicMock, + mock_load_cursor: MagicMock, + mock_load_codex: MagicMock, mock_ctx: MagicMock, tmp_path: Path, ) -> None: """The global config file carries only the global MCP servers; the plugin's own .mcp.json content + path + metadata enrich enabled_plugins (no merge into the global).""" - mock_get_auth.return_value = MagicMock() + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') mock_ai_client = MagicMock() mock_get_client.return_value = mock_ai_client + mock_load_cursor.return_value = None + mock_load_codex.return_value = None # Set up a fake plugin directory on disk. - plugin_dir = tmp_path / 'ai-prompts' + plugin_dir = tmp_path / 'dummy-plugin' plugin_dir.mkdir() (plugin_dir / '.mcp.json').write_text( - json.dumps({'mcpServers': {'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}}}) + json.dumps({'mcpServers': {'dummy-server': {'command': 'dummy-command', 'args': ['serve']}}}) ) claude_plugin_dir = plugin_dir / '.claude-plugin' claude_plugin_dir.mkdir() (claude_plugin_dir / 'plugin.json').write_text( - json.dumps({'name': 'cycode-dev', 'version': '1.0.28', 'description': 'Shared skills'}) + json.dumps({'name': 'dummy-plugin', 'version': '1.0.28', 'description': 'Dummy plugin'}) ) - user_mcp_servers = {'gitlab': {'command': 'npx'}} + user_mcp_servers = {'dummy-global': {'command': 'dummy-command'}} mock_load_config.return_value = {'mcpServers': user_mcp_servers} mock_load_settings.return_value = { - 'enabledPlugins': {'cycode-dev@cycode-marketplace': True}, - 'extraKnownMarketplaces': {'cycode-marketplace': {'source': {'source': 'directory', 'path': str(plugin_dir)}}}, + 'enabledPlugins': {'dummy-plugin@dummy-marketplace': True}, + 'extraKnownMarketplaces': {'dummy-marketplace': {'source': {'source': 'directory', 'path': str(plugin_dir)}}}, } payload = {'session_id': 'session-123'} @@ -290,24 +333,25 @@ def test_claude_code_reports_global_file_and_plugin_metadata( with patch('sys.stdin', new=StringIO(json.dumps(payload))): session_start_command(mock_ctx, ide='claude-code') - plugin_mcp = {'mcpServers': {'aspire': {'command': 'aspire', 'args': ['mcp', 'start']}}} + plugin_mcp = {'mcpServers': {'dummy-server': {'command': 'dummy-command', 'args': ['serve']}}} + claude_file = { + 'path': str(_claude_mod._CLAUDE_CONFIG_PATH), + 'content': json.dumps({'mcpServers': user_mcp_servers}), + } mock_ai_client.report_session_context.assert_called_once_with( hostname=ANY, platform_name=ANY, os_version=ANY, serial_number=ANY, last_login_user=ANY, - global_config_file={ - 'path': str(_claude_mod._CLAUDE_CONFIG_PATH), - 'content': json.dumps({'mcpServers': user_mcp_servers}), - }, + config_files=[claude_file], enabled_plugins={ - 'cycode-dev@cycode-marketplace': { + 'dummy-plugin@dummy-marketplace': { 'enabled': True, - 'name': 'cycode-dev', + 'name': 'dummy-plugin', 'version': '1.0.28', - 'description': 'Shared skills', - 'mcp_server_names': ['aspire'], + 'description': 'Dummy plugin', + 'mcp_server_names': ['dummy-server'], 'mcp_config_file_path': str(plugin_dir / '.mcp.json'), 'mcp_config_file': json.dumps(plugin_mcp), } @@ -316,89 +360,199 @@ def test_claude_code_reports_global_file_and_plugin_metadata( ) +@patch.object(_codex_mod, '_load_codex_config') @patch.object(_claude_mod, 'load_claude_settings') @patch.object(_claude_mod, 'load_claude_config') +@patch.object(_cursor_mod, '_load_cursor_mcp_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') -def test_claude_code_no_mcp_servers_no_plugins_skips_report( +def test_cursor_trigger_sweeps_other_ides( mock_get_auth: MagicMock, mock_get_client: MagicMock, + mock_load_cursor: MagicMock, mock_load_config: MagicMock, mock_load_settings: MagicMock, + mock_load_codex: MagicMock, mock_ctx: MagicMock, ) -> None: - """When no mcpServers and no plugins, report_session_context should not be called.""" - mock_get_auth.return_value = MagicMock() + """A Cursor-triggered session start also reports Claude's config via config_files.""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') mock_ai_client = MagicMock() mock_get_client.return_value = mock_ai_client - mock_load_config.return_value = {'oauthAccount': {'emailAddress': 'test@test.com'}} + cursor_servers = {'github': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-github']}} + claude_servers = {'gitlab': {'command': 'npx'}} + mock_load_cursor.return_value = {'mcpServers': cursor_servers} + mock_load_config.return_value = {'mcpServers': claude_servers} mock_load_settings.return_value = None + mock_load_codex.return_value = None - payload = {'session_id': 'session-123'} + payload = {'conversation_id': 'conv-456', 'model': 'gpt-4'} + + with patch('sys.stdin', new=StringIO(json.dumps(payload))): + session_start_command(mock_ctx, ide='cursor') + cursor_file = { + 'path': str(Path.home() / '.cursor' / 'mcp.json'), + 'content': json.dumps({'mcpServers': cursor_servers}), + } + claude_file = { + 'path': str(_claude_mod._CLAUDE_CONFIG_PATH), + 'content': json.dumps({'mcpServers': claude_servers}), + } + # config_files is sorted by path for a stable digest (~/.claude.json < ~/.cursor/mcp.json). + mock_ai_client.report_session_context.assert_called_once_with( + hostname=ANY, + platform_name=ANY, + os_version=ANY, + serial_number=ANY, + last_login_user=ANY, + config_files=[claude_file, cursor_file], + enabled_plugins={}, + user_email=None, + ) + + +# Dedup cache tests + + +def _run_session_start(mock_ctx: MagicMock, payload: dict) -> None: with patch('sys.stdin', new=StringIO(json.dumps(payload))): session_start_command(mock_ctx, ide='claude-code') - mock_ai_client.report_session_context.assert_not_called() +@patch.object(_session_start_mod, 'collect_all_session_contexts') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_unchanged_context_skips_second_report( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_collect: MagicMock, + mock_ctx: MagicMock, +) -> None: + """An identical payload within the TTL is sent once; the second session start skips it.""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_collect.return_value = ({'cursor': {'path': '/p', 'content': 'c'}}, {}) + + _run_session_start(mock_ctx, {'session_id': 'session-1'}) + _run_session_start(mock_ctx, {'session_id': 'session-2'}) -@patch.object(_cursor_mod, '_load_cursor_mcp_config') + mock_ai_client.report_session_context.assert_called_once() + + +@patch.object(_session_start_mod, 'collect_all_session_contexts') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') -def test_cursor_reports_mcp_servers( +def test_changed_context_resends( mock_get_auth: MagicMock, mock_get_client: MagicMock, - mock_load_cursor: MagicMock, + mock_collect: MagicMock, mock_ctx: MagicMock, ) -> None: - """Cursor should report MCP servers from ~/.cursor/mcp.json.""" - mock_get_auth.return_value = MagicMock() + """A change in the collected inventory busts the cache immediately.""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') mock_ai_client = MagicMock() mock_get_client.return_value = mock_ai_client - mcp_servers = {'github': {'command': 'npx', 'args': ['-y', '@modelcontextprotocol/server-github']}} - mock_load_cursor.return_value = {'mcpServers': mcp_servers} - payload = {'conversation_id': 'conv-456', 'model': 'gpt-4'} + mock_collect.return_value = ({'cursor': {'path': '/p', 'content': 'c1'}}, {}) + _run_session_start(mock_ctx, {'session_id': 'session-1'}) - with patch('sys.stdin', new=StringIO(json.dumps(payload))): - session_start_command(mock_ctx, ide='cursor') + mock_collect.return_value = ({'cursor': {'path': '/p', 'content': 'c2'}}, {}) + _run_session_start(mock_ctx, {'session_id': 'session-2'}) - mock_ai_client.report_session_context.assert_called_once_with( - hostname=ANY, - platform_name=ANY, - os_version=ANY, - serial_number=ANY, - last_login_user=ANY, - global_config_file={ - 'path': str(Path.home() / '.cursor' / 'mcp.json'), - 'content': json.dumps({'mcpServers': mcp_servers}), - }, - enabled_plugins={}, - user_email=None, - ) + assert mock_ai_client.report_session_context.call_count == 2 -@patch.object(_cursor_mod, '_load_cursor_mcp_config') +@patch.object(_session_start_mod, 'collect_all_session_contexts') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') -def test_cursor_no_mcp_servers_skips_report( +def test_tenant_change_resends( mock_get_auth: MagicMock, mock_get_client: MagicMock, - mock_load_cursor: MagicMock, + mock_collect: MagicMock, mock_ctx: MagicMock, ) -> None: - """Cursor with no MCP config file should skip report_session_context.""" - mock_get_auth.return_value = MagicMock() + """Re-authenticating against a different tenant must re-send the same inventory.""" mock_ai_client = MagicMock() mock_get_client.return_value = mock_ai_client - mock_load_cursor.return_value = None + mock_collect.return_value = ({'cursor': {'path': '/p', 'content': 'c'}}, {}) - payload = {'conversation_id': 'conv-456', 'model': 'gpt-4'} + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') + _run_session_start(mock_ctx, {'session_id': 'session-1'}) - with patch('sys.stdin', new=StringIO(json.dumps(payload))): - session_start_command(mock_ctx, ide='cursor') + mock_get_auth.return_value = MagicMock(tenant_id='tenant-2') + _run_session_start(mock_ctx, {'session_id': 'session-2'}) + + assert mock_ai_client.report_session_context.call_count == 2 + + +@patch.object(_session_start_mod, 'collect_all_session_contexts') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_failed_report_is_not_cached( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_collect: MagicMock, + mock_ctx: MagicMock, +) -> None: + """A failed send must not populate the cache - the next session retries.""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') + mock_ai_client = MagicMock() + mock_ai_client.report_session_context.return_value = False + mock_get_client.return_value = mock_ai_client + mock_collect.return_value = ({'cursor': {'path': '/p', 'content': 'c'}}, {}) + + _run_session_start(mock_ctx, {'session_id': 'session-1'}) + _run_session_start(mock_ctx, {'session_id': 'session-2'}) + + assert mock_ai_client.report_session_context.call_count == 2 + + +@patch.object(_session_start_mod, 'collect_all_session_contexts') +@patch.object(_session_start_mod, 'get_ai_security_manager_client') +@patch.object(_session_start_mod, 'get_authorization_info') +def test_expired_ttl_resends( + mock_get_auth: MagicMock, + mock_get_client: MagicMock, + mock_collect: MagicMock, + mock_ctx: MagicMock, +) -> None: + """After the TTL, an unchanged payload is re-sent (self-healing / liveness heartbeat).""" + mock_get_auth.return_value = MagicMock(tenant_id='tenant-1') + mock_ai_client = MagicMock() + mock_get_client.return_value = mock_ai_client + mock_collect.return_value = ({'cursor': {'path': '/p', 'content': 'c'}}, {}) + + _run_session_start(mock_ctx, {'session_id': 'session-1'}) + + # Age the cache entry past the TTL. + cache_path = _session_start_mod._session_context_cache_path() + cache = json.loads(cache_path.read_text(encoding='utf-8')) + cache['sent_at'] = cache['sent_at'] - _session_start_mod._SESSION_CONTEXT_TTL_SECONDS - 1 + cache_path.write_text(json.dumps(cache), encoding='utf-8') + + _run_session_start(mock_ctx, {'session_id': 'session-2'}) + + assert mock_ai_client.report_session_context.call_count == 2 + + +# Cross-IDE sweep tests + + +def test_collect_all_session_contexts_merges_plugins_first_wins() -> None: + """A plugin key present in two IDEs keeps the first registered IDE's entry.""" + claude_plugin = {'enabled': True, 'version': '1.0.0'} + codex_plugin = {'enabled': True, 'version': '2.0.0'} + + with ( + patch.object(IDES['cursor'], 'get_session_context', return_value=(None, {})), + patch.object(IDES['claude-code'], 'get_session_context', return_value=(None, {'plug@m': claude_plugin})), + patch.object(IDES['codex'], 'get_session_context', return_value=(None, {'plug@m': codex_plugin})), + ): + _, plugins = collect_all_session_contexts() - mock_ai_client.report_session_context.assert_not_called() + assert plugins == {'plug@m': claude_plugin} @patch.object(_session_start_mod, 'handle_auth_exception') From 8be08edc66d053fc345685f19617c1bcc2949ec5 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:36:49 +0300 Subject: [PATCH 327/347] CM-68554: Build and sign Windows onedir executable (#494) Co-authored-by: Claude Fable 5 --- .github/workflows/build_executable.yml | 38 ++++++++++++++++++++++---- process_executable_file.py | 13 +++++---- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index a9e5eddc..2b81f2cc 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -26,8 +26,6 @@ jobs: exclude: - os: ubuntu-22.04 mode: onedir - - os: windows-2022 - mode: onedir runs-on: ${{ matrix.os }} @@ -249,18 +247,46 @@ jobs: C:\Windows\System32\certutil.exe -csp "DigiCert Signing Manager KSP" -key -user 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" + :: sign executable (in onedir mode the exe lives inside the collected directory) + set "EXE_PATH=.\dist\cycode-cli.exe" + if "${{ matrix.mode }}"=="onedir" set "EXE_PATH=.\dist\cycode-cli\cycode-cli.exe" + signtool.exe sign /sha1 %SM_CODE_SIGNING_CERT_SHA1_HASH% /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 "%EXE_PATH%" + + - name: Sign unsigned onedir binaries (Windows) + if: runner.os == 'Windows' && matrix.mode == 'onedir' + shell: powershell + 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: | + # Vendor binaries (PSF-signed stdlib .pyds, python3xx.dll, Microsoft VC runtime) already + # carry valid signatures; re-signing would replace them with ours. Sign only the unsigned + # ones (PyInstaller-generated and third-party wheel binaries). + $files = Get-ChildItem -Path dist\cycode-cli\_internal -Recurse -Include *.dll,*.pyd,*.exe | + Where-Object { (Get-AuthenticodeSignature $_.FullName).Status -eq 'NotSigned' } + if (-not $files) { + Write-Host 'No unsigned binaries found' + exit 0 + } + Write-Host "Signing $($files.Count) unsigned binaries:" + $files.FullName | Write-Host + signtool.exe sign /sha1 $env:SM_CODE_SIGNING_CERT_SHA1_HASH /tr http://timestamp.digicert.com /td SHA256 /fd SHA256 @($files.FullName) + exit $LASTEXITCODE - name: Test Windows signed executable if: runner.os == 'Windows' shell: cmd run: | + set "EXE_PATH=.\dist\cycode-cli.exe" + if "${{ matrix.mode }}"=="onedir" set "EXE_PATH=.\dist\cycode-cli\cycode-cli.exe" + :: call executable and expect correct output - .\dist\cycode-cli.exe status + "%EXE_PATH%" status :: verify signature - signtool.exe verify /v /pa ".\dist\cycode-cli.exe" + signtool.exe verify /v /pa "%EXE_PATH%" - name: Prepare files for artifact and release (rename and calculate sha256) run: echo "ARTIFACT_NAME=$(./process_executable_file.py dist/cycode-cli)" >> $GITHUB_ENV diff --git a/process_executable_file.py b/process_executable_file.py index 36d6d0d6..19cfbb44 100755 --- a/process_executable_file.py +++ b/process_executable_file.py @@ -22,7 +22,7 @@ _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': Template('cycode-win$suffix$ext'), } _WINDOWS = 'windows' _WINDOWS_EXECUTABLE_SUFFIX = '.exe' @@ -87,8 +87,7 @@ def get_cli_file_name(suffix: str = '', ext: str = '') -> str: 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) + return _OS_TO_CLI_DIST_TEMPLATE[os_name].substitute(suffix=suffix, ext=ext) def get_cli_file_suffix(is_onedir: bool) -> str: @@ -117,7 +116,9 @@ def write_hashes_db_to_file(hashes: DirHashes, output_path: str) -> None: def get_cli_filename(is_onedir: bool) -> str: - return get_cli_file_name(get_cli_file_suffix(is_onedir)) + # onedir is distributed as an archive of a directory, so only onefile carries .exe + ext = _WINDOWS_EXECUTABLE_SUFFIX if get_os_name() == _WINDOWS and not is_onedir else '' + return get_cli_file_name(suffix=get_cli_file_suffix(is_onedir), ext=ext) def get_cli_path(output_path: Path, is_onedir: bool) -> str: @@ -125,7 +126,7 @@ def get_cli_path(output_path: Path, is_onedir: bool) -> str: 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) + return get_cli_filename(is_onedir) + _HASH_FILE_EXT def get_cli_hash_path(output_path: Path, is_onedir: bool) -> str: @@ -133,7 +134,7 @@ def get_cli_hash_path(output_path: Path, is_onedir: bool) -> str: def get_cli_archive_filename(is_onedir: bool) -> str: - return get_cli_file_name(suffix=get_cli_file_suffix(is_onedir)) + return get_cli_filename(is_onedir) def get_cli_archive_path(output_path: Path, is_onedir: bool) -> str: From 815461f84a4755315110253ce977e920294c5490 Mon Sep 17 00:00:00 2001 From: omer-roth Date: Wed, 15 Jul 2026 21:17:27 +0300 Subject: [PATCH 328/347] CM-68872: gate secret-scan async (presigned) flow behind CYCODE_SECRET_SCAN_ASYNC (#496) Co-authored-by: Claude Opus 4.8 --- cycode/cli/consts.py | 6 ++- cycode/cli/files_collector/zip_documents.py | 6 ++- cycode/cli/utils/scan_utils.py | 7 ++- cycode/config.py | 9 +++- tests/cli/commands/scan/test_code_scanner.py | 19 +++++--- .../scan/test_commit_range_scanner.py | 5 ++ tests/cli/utils/__init__.py | 0 tests/cli/utils/test_scan_utils.py | 46 +++++++++++++++++++ 8 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 tests/cli/utils/__init__.py create mode 100644 tests/cli/utils/test_scan_utils.py diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 37ef2298..58924c2d 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -222,13 +222,15 @@ 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, SECRET_SCAN_TYPE} +PRESIGNED_UPLOAD_SCAN_TYPES = {SAST_SCAN_TYPE} +# Secret scans use the previous (batched / API-upload) flow by default. The presigned S3 async flow is +# opt-in via the SECRET_SCAN_ASYNC_ENV_VAR_NAME env var; see should_use_presigned_upload. +SECRET_SCAN_ASYNC_ENV_VAR_NAME = 'CYCODE_SECRET_SCAN_ASYNC' 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: PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES, - SECRET_SCAN_TYPE: PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES, } # scan in batches diff --git a/cycode/cli/files_collector/zip_documents.py b/cycode/cli/files_collector/zip_documents.py index 7927bdc6..4f66429f 100644 --- a/cycode/cli/files_collector/zip_documents.py +++ b/cycode/cli/files_collector/zip_documents.py @@ -6,13 +6,17 @@ 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.cli.utils.scan_utils import should_use_presigned_upload from cycode.logger import get_logger logger = get_logger('ZIP') def _validate_zip_file_size(scan_type: str, zip_file_size: int) -> None: - max_size_limit = consts.ZIP_MAX_SIZE_LIMIT_IN_BYTES.get(scan_type, consts.DEFAULT_ZIP_MAX_SIZE_LIMIT_IN_BYTES) + if should_use_presigned_upload(scan_type): + max_size_limit = consts.PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES + else: + 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) diff --git a/cycode/cli/utils/scan_utils.py b/cycode/cli/utils/scan_utils.py index 819a4116..21131343 100644 --- a/cycode/cli/utils/scan_utils.py +++ b/cycode/cli/utils/scan_utils.py @@ -7,6 +7,7 @@ from cycode.cli import consts from cycode.cli.cli_types import SeverityOption +from cycode.config import parse_bool if TYPE_CHECKING: from cycode.cli.models import LocalScanResult @@ -33,7 +34,11 @@ def is_cycodeignore_allowed_by_scan_config(ctx: typer.Context) -> bool: def should_use_presigned_upload(scan_type: str) -> bool: - return scan_type in consts.PRESIGNED_UPLOAD_SCAN_TYPES + if scan_type in consts.PRESIGNED_UPLOAD_SCAN_TYPES: + return True + if scan_type == consts.SECRET_SCAN_TYPE: + return parse_bool(os.getenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME)) + return False def generate_unique_scan_id() -> UUID: diff --git a/cycode/config.py b/cycode/config.py index f4306b31..b17dadaa 100644 --- a/cycode/config.py +++ b/cycode/config.py @@ -19,11 +19,18 @@ def get_val_as_string(key: str) -> str: return configuration.get(key) +_TRUTHY_STRING_VALUES = {'true', '1', 'yes', 'y', 'on', 'enabled'} + + +def parse_bool(value: Optional[str]) -> bool: + return value is not None and value.lower() in _TRUTHY_STRING_VALUES + + 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'} + return parse_bool(configuration[key]) def get_val_as_int(key: str) -> Optional[int]: diff --git a/tests/cli/commands/scan/test_code_scanner.py b/tests/cli/commands/scan/test_code_scanner.py index 8b4a30b3..11d0fd46 100644 --- a/tests/cli/commands/scan/test_code_scanner.py +++ b/tests/cli/commands/scan/test_code_scanner.py @@ -168,14 +168,16 @@ def test_entrypoint_cycode_not_added_for_single_file( @pytest.mark.parametrize( - ('scan_type', 'command_scan_type', 'sync_option', 'expect_presigned'), + ('scan_type', 'command_scan_type', 'sync_option', 'secret_async_env', 'expect_presigned'), [ # SAST keeps uploading directly to S3 via a presigned URL (regression guard for the new sync gate). - (consts.SAST_SCAN_TYPE, 'path', False, True), - # Async secret scans now upload as a single file directly to S3 via a presigned URL. - (consts.SECRET_SCAN_TYPE, 'path', False, True), - # A --sync secret scan must stay on the batched inline path and never build one giant zip. - (consts.SECRET_SCAN_TYPE, 'path', True, False), + (consts.SAST_SCAN_TYPE, 'path', False, False, True), + # Secret scans use the previous batched flow by default (presigned async is opt-in). + (consts.SECRET_SCAN_TYPE, 'path', False, False, False), + # With CYCODE_SECRET_SCAN_ASYNC enabled, secret scans upload as a single file directly to S3. + (consts.SECRET_SCAN_TYPE, 'path', False, True, True), + # A --sync secret scan must stay on the batched inline path even when async is enabled. + (consts.SECRET_SCAN_TYPE, 'path', True, True, False), ], ) @patch('cycode.cli.apps.scan.code_scanner.print_local_scan_results') @@ -192,8 +194,13 @@ def test_scan_documents_routes_upload_by_scan_type_and_sync( scan_type: str, command_scan_type: str, sync_option: bool, + secret_async_env: bool, expect_presigned: bool, + monkeypatch: pytest.MonkeyPatch, ) -> None: + if secret_async_env: + monkeypatch.setenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, 'true') + mock_presigned_upload.return_value = ([], []) mock_batched_scan.return_value = ([], []) diff --git a/tests/cli/commands/scan/test_commit_range_scanner.py b/tests/cli/commands/scan/test_commit_range_scanner.py index a4a6c58b..8b8cc54b 100644 --- a/tests/cli/commands/scan/test_commit_range_scanner.py +++ b/tests/cli/commands/scan/test_commit_range_scanner.py @@ -1,5 +1,7 @@ from unittest.mock import MagicMock, Mock, patch +import pytest + from cycode.cli import consts from cycode.cli.apps.scan.commit_range_scanner import _scan_commit_range_documents from cycode.cli.exceptions import custom_exceptions @@ -25,7 +27,10 @@ def test_commit_range_scan_falls_back_to_api_when_presigned_upload_raises_wrappe mock_print: Mock, mock_handle_exception: Mock, mock_report_status: Mock, + monkeypatch: pytest.MonkeyPatch, ) -> None: + # Secret uses the presigned flow only when async is opted in. + monkeypatch.setenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, 'true') # SlowUploadConnectionError is a CycodeError, not a requests.RequestException — the presigned # commit-range fallback must still catch it and retry via the Cycode API. mock_v4_async.side_effect = custom_exceptions.SlowUploadConnectionError diff --git a/tests/cli/utils/__init__.py b/tests/cli/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/cli/utils/test_scan_utils.py b/tests/cli/utils/test_scan_utils.py new file mode 100644 index 00000000..d2bf0438 --- /dev/null +++ b/tests/cli/utils/test_scan_utils.py @@ -0,0 +1,46 @@ +import pytest + +from cycode.cli import consts +from cycode.cli.exceptions import custom_exceptions +from cycode.cli.files_collector.zip_documents import _validate_zip_file_size +from cycode.cli.utils.scan_utils import should_use_presigned_upload + + +def test_sast_always_uses_presigned_upload() -> None: + assert should_use_presigned_upload(consts.SAST_SCAN_TYPE) is True + + +def test_secret_does_not_use_presigned_upload_by_default(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, raising=False) + assert should_use_presigned_upload(consts.SECRET_SCAN_TYPE) is False + + +@pytest.mark.parametrize('env_value', ['true', 'True', '1', 'yes', 'y', 'on', 'enabled']) +def test_secret_uses_presigned_upload_when_env_enabled(monkeypatch: pytest.MonkeyPatch, env_value: str) -> None: + monkeypatch.setenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, env_value) + assert should_use_presigned_upload(consts.SECRET_SCAN_TYPE) is True + + +@pytest.mark.parametrize('env_value', ['false', '0', 'no', '', 'off']) +def test_secret_ignores_non_truthy_env_values(monkeypatch: pytest.MonkeyPatch, env_value: str) -> None: + monkeypatch.setenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, env_value) + assert should_use_presigned_upload(consts.SECRET_SCAN_TYPE) is False + + +def test_sca_never_uses_presigned_upload() -> None: + assert should_use_presigned_upload(consts.SCA_SCAN_TYPE) is False + + +def test_secret_zip_size_limit_uses_default_by_default(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, raising=False) + # A zip just above the default 20 MB limit must be rejected on the previous (batched) flow. + with pytest.raises(custom_exceptions.ZipTooLargeError): + _validate_zip_file_size(consts.SECRET_SCAN_TYPE, consts.DEFAULT_ZIP_MAX_SIZE_LIMIT_IN_BYTES + 1) + + +def test_secret_zip_size_limit_uses_presigned_limit_when_env_enabled(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, 'true') + # The same zip fits under the 5 GB presigned limit when async is enabled. + _validate_zip_file_size(consts.SECRET_SCAN_TYPE, consts.DEFAULT_ZIP_MAX_SIZE_LIMIT_IN_BYTES + 1) + with pytest.raises(custom_exceptions.ZipTooLargeError): + _validate_zip_file_size(consts.SECRET_SCAN_TYPE, consts.PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES + 1) From a57ba409816bfb73666e4001ae891724fb9b4aeb Mon Sep 17 00:00:00 2001 From: omer-roth Date: Thu, 16 Jul 2026 12:24:25 +0300 Subject: [PATCH 329/347] =?UTF-8?q?Revert=20"CM-68872:=20gate=20secret-sca?= =?UTF-8?q?n=20async=20(presigned)=20flow=20behind=20CYCO=E2=80=A6=20(#497?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cycode/cli/consts.py | 6 +-- cycode/cli/files_collector/zip_documents.py | 6 +-- cycode/cli/utils/scan_utils.py | 7 +-- cycode/config.py | 9 +--- tests/cli/commands/scan/test_code_scanner.py | 19 +++----- .../scan/test_commit_range_scanner.py | 5 -- tests/cli/utils/__init__.py | 0 tests/cli/utils/test_scan_utils.py | 46 ------------------- 8 files changed, 11 insertions(+), 87 deletions(-) delete mode 100644 tests/cli/utils/__init__.py delete mode 100644 tests/cli/utils/test_scan_utils.py diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 58924c2d..37ef2298 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -222,15 +222,13 @@ 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} -# Secret scans use the previous (batched / API-upload) flow by default. The presigned S3 async flow is -# opt-in via the SECRET_SCAN_ASYNC_ENV_VAR_NAME env var; see should_use_presigned_upload. -SECRET_SCAN_ASYNC_ENV_VAR_NAME = 'CYCODE_SECRET_SCAN_ASYNC' +PRESIGNED_UPLOAD_SCAN_TYPES = {SAST_SCAN_TYPE, SECRET_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: PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES, + SECRET_SCAN_TYPE: PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES, } # scan in batches diff --git a/cycode/cli/files_collector/zip_documents.py b/cycode/cli/files_collector/zip_documents.py index 4f66429f..7927bdc6 100644 --- a/cycode/cli/files_collector/zip_documents.py +++ b/cycode/cli/files_collector/zip_documents.py @@ -6,17 +6,13 @@ 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.cli.utils.scan_utils import should_use_presigned_upload from cycode.logger import get_logger logger = get_logger('ZIP') def _validate_zip_file_size(scan_type: str, zip_file_size: int) -> None: - if should_use_presigned_upload(scan_type): - max_size_limit = consts.PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES - else: - max_size_limit = consts.ZIP_MAX_SIZE_LIMIT_IN_BYTES.get(scan_type, consts.DEFAULT_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) diff --git a/cycode/cli/utils/scan_utils.py b/cycode/cli/utils/scan_utils.py index 21131343..819a4116 100644 --- a/cycode/cli/utils/scan_utils.py +++ b/cycode/cli/utils/scan_utils.py @@ -7,7 +7,6 @@ from cycode.cli import consts from cycode.cli.cli_types import SeverityOption -from cycode.config import parse_bool if TYPE_CHECKING: from cycode.cli.models import LocalScanResult @@ -34,11 +33,7 @@ def is_cycodeignore_allowed_by_scan_config(ctx: typer.Context) -> bool: def should_use_presigned_upload(scan_type: str) -> bool: - if scan_type in consts.PRESIGNED_UPLOAD_SCAN_TYPES: - return True - if scan_type == consts.SECRET_SCAN_TYPE: - return parse_bool(os.getenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME)) - return False + return scan_type in consts.PRESIGNED_UPLOAD_SCAN_TYPES def generate_unique_scan_id() -> UUID: diff --git a/cycode/config.py b/cycode/config.py index b17dadaa..f4306b31 100644 --- a/cycode/config.py +++ b/cycode/config.py @@ -19,18 +19,11 @@ def get_val_as_string(key: str) -> str: return configuration.get(key) -_TRUTHY_STRING_VALUES = {'true', '1', 'yes', 'y', 'on', 'enabled'} - - -def parse_bool(value: Optional[str]) -> bool: - return value is not None and value.lower() in _TRUTHY_STRING_VALUES - - def get_val_as_bool(key: str, default: bool = False) -> bool: if key not in configuration: return default - return parse_bool(configuration[key]) + return configuration[key].lower() in {'true', '1', 'yes', 'y', 'on', 'enabled'} def get_val_as_int(key: str) -> Optional[int]: diff --git a/tests/cli/commands/scan/test_code_scanner.py b/tests/cli/commands/scan/test_code_scanner.py index 11d0fd46..8b4a30b3 100644 --- a/tests/cli/commands/scan/test_code_scanner.py +++ b/tests/cli/commands/scan/test_code_scanner.py @@ -168,16 +168,14 @@ def test_entrypoint_cycode_not_added_for_single_file( @pytest.mark.parametrize( - ('scan_type', 'command_scan_type', 'sync_option', 'secret_async_env', 'expect_presigned'), + ('scan_type', 'command_scan_type', 'sync_option', 'expect_presigned'), [ # SAST keeps uploading directly to S3 via a presigned URL (regression guard for the new sync gate). - (consts.SAST_SCAN_TYPE, 'path', False, False, True), - # Secret scans use the previous batched flow by default (presigned async is opt-in). - (consts.SECRET_SCAN_TYPE, 'path', False, False, False), - # With CYCODE_SECRET_SCAN_ASYNC enabled, secret scans upload as a single file directly to S3. - (consts.SECRET_SCAN_TYPE, 'path', False, True, True), - # A --sync secret scan must stay on the batched inline path even when async is enabled. - (consts.SECRET_SCAN_TYPE, 'path', True, True, False), + (consts.SAST_SCAN_TYPE, 'path', False, True), + # Async secret scans now upload as a single file directly to S3 via a presigned URL. + (consts.SECRET_SCAN_TYPE, 'path', False, True), + # A --sync secret scan must stay on the batched inline path and never build one giant zip. + (consts.SECRET_SCAN_TYPE, 'path', True, False), ], ) @patch('cycode.cli.apps.scan.code_scanner.print_local_scan_results') @@ -194,13 +192,8 @@ def test_scan_documents_routes_upload_by_scan_type_and_sync( scan_type: str, command_scan_type: str, sync_option: bool, - secret_async_env: bool, expect_presigned: bool, - monkeypatch: pytest.MonkeyPatch, ) -> None: - if secret_async_env: - monkeypatch.setenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, 'true') - mock_presigned_upload.return_value = ([], []) mock_batched_scan.return_value = ([], []) diff --git a/tests/cli/commands/scan/test_commit_range_scanner.py b/tests/cli/commands/scan/test_commit_range_scanner.py index 8b8cc54b..a4a6c58b 100644 --- a/tests/cli/commands/scan/test_commit_range_scanner.py +++ b/tests/cli/commands/scan/test_commit_range_scanner.py @@ -1,7 +1,5 @@ from unittest.mock import MagicMock, Mock, patch -import pytest - from cycode.cli import consts from cycode.cli.apps.scan.commit_range_scanner import _scan_commit_range_documents from cycode.cli.exceptions import custom_exceptions @@ -27,10 +25,7 @@ def test_commit_range_scan_falls_back_to_api_when_presigned_upload_raises_wrappe mock_print: Mock, mock_handle_exception: Mock, mock_report_status: Mock, - monkeypatch: pytest.MonkeyPatch, ) -> None: - # Secret uses the presigned flow only when async is opted in. - monkeypatch.setenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, 'true') # SlowUploadConnectionError is a CycodeError, not a requests.RequestException — the presigned # commit-range fallback must still catch it and retry via the Cycode API. mock_v4_async.side_effect = custom_exceptions.SlowUploadConnectionError diff --git a/tests/cli/utils/__init__.py b/tests/cli/utils/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/cli/utils/test_scan_utils.py b/tests/cli/utils/test_scan_utils.py deleted file mode 100644 index d2bf0438..00000000 --- a/tests/cli/utils/test_scan_utils.py +++ /dev/null @@ -1,46 +0,0 @@ -import pytest - -from cycode.cli import consts -from cycode.cli.exceptions import custom_exceptions -from cycode.cli.files_collector.zip_documents import _validate_zip_file_size -from cycode.cli.utils.scan_utils import should_use_presigned_upload - - -def test_sast_always_uses_presigned_upload() -> None: - assert should_use_presigned_upload(consts.SAST_SCAN_TYPE) is True - - -def test_secret_does_not_use_presigned_upload_by_default(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, raising=False) - assert should_use_presigned_upload(consts.SECRET_SCAN_TYPE) is False - - -@pytest.mark.parametrize('env_value', ['true', 'True', '1', 'yes', 'y', 'on', 'enabled']) -def test_secret_uses_presigned_upload_when_env_enabled(monkeypatch: pytest.MonkeyPatch, env_value: str) -> None: - monkeypatch.setenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, env_value) - assert should_use_presigned_upload(consts.SECRET_SCAN_TYPE) is True - - -@pytest.mark.parametrize('env_value', ['false', '0', 'no', '', 'off']) -def test_secret_ignores_non_truthy_env_values(monkeypatch: pytest.MonkeyPatch, env_value: str) -> None: - monkeypatch.setenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, env_value) - assert should_use_presigned_upload(consts.SECRET_SCAN_TYPE) is False - - -def test_sca_never_uses_presigned_upload() -> None: - assert should_use_presigned_upload(consts.SCA_SCAN_TYPE) is False - - -def test_secret_zip_size_limit_uses_default_by_default(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, raising=False) - # A zip just above the default 20 MB limit must be rejected on the previous (batched) flow. - with pytest.raises(custom_exceptions.ZipTooLargeError): - _validate_zip_file_size(consts.SECRET_SCAN_TYPE, consts.DEFAULT_ZIP_MAX_SIZE_LIMIT_IN_BYTES + 1) - - -def test_secret_zip_size_limit_uses_presigned_limit_when_env_enabled(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv(consts.SECRET_SCAN_ASYNC_ENV_VAR_NAME, 'true') - # The same zip fits under the 5 GB presigned limit when async is enabled. - _validate_zip_file_size(consts.SECRET_SCAN_TYPE, consts.DEFAULT_ZIP_MAX_SIZE_LIMIT_IN_BYTES + 1) - with pytest.raises(custom_exceptions.ZipTooLargeError): - _validate_zip_file_size(consts.SECRET_SCAN_TYPE, consts.PRESIGNED_LINK_UPLOADED_ZIP_MAX_SIZE_LIMIT_IN_BYTES + 1) From 2d823528f7faa0314eae110d7b48e998c957cd54 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:42:13 +0300 Subject: [PATCH 330/347] CM-64462: add GitHub Copilot (VS Code) support to AI guardrails (#498) Co-authored-by: Claude Fable 5 --- .../cli/apps/ai_guardrails/hooks_manager.py | 17 +- .../cli/apps/ai_guardrails/ides/__init__.py | 3 +- cycode/cli/apps/ai_guardrails/ides/base.py | 19 + .../apps/ai_guardrails/ides/claude_code.py | 6 +- cycode/cli/apps/ai_guardrails/ides/codex.py | 9 +- cycode/cli/apps/ai_guardrails/ides/copilot.py | 436 ++++++++++++++++++ cycode/cli/apps/ai_guardrails/ides/cursor.py | 4 +- .../apps/ai_guardrails/scan/scan_command.py | 27 +- .../ai_guardrails/ides/test_claude_code.py | 32 +- .../commands/ai_guardrails/ides/test_codex.py | 16 +- .../ai_guardrails/ides/test_contract.py | 10 +- .../ai_guardrails/ides/test_copilot.py | 416 +++++++++++++++++ .../ai_guardrails/scan/test_scan_command.py | 64 ++- .../ai_guardrails/test_hooks_manager.py | 56 ++- .../test_session_start_command.py | 16 +- 15 files changed, 1094 insertions(+), 37 deletions(-) create mode 100644 cycode/cli/apps/ai_guardrails/ides/copilot.py create mode 100644 tests/cli/commands/ai_guardrails/ides/test_copilot.py diff --git a/cycode/cli/apps/ai_guardrails/hooks_manager.py b/cycode/cli/apps/ai_guardrails/hooks_manager.py index 192bb9f3..b7e55b86 100644 --- a/cycode/cli/apps/ai_guardrails/hooks_manager.py +++ b/cycode/cli/apps/ai_guardrails/hooks_manager.py @@ -22,15 +22,22 @@ _CYCODE_COMMAND_MARKERS = ('cycode ai-guardrails',) +# Command-carrying fields of a flat hook entry. Copilot entries use per-OS +# `bash`/`powershell` fields instead of `command`. +_COMMAND_FIELDS = ('command', 'bash', 'powershell') + def _is_cycode_command(command: str) -> bool: return any(marker in command for marker in _CYCODE_COMMAND_MARKERS) +def _has_cycode_command_field(entry: dict) -> bool: + return any(_is_cycode_command(entry.get(field, '')) for field in _COMMAND_FIELDS) + + def is_cycode_hook_entry(entry: dict) -> bool: """True if any hook inside ``entry`` is owned by Cycode.""" - command = entry.get('command', '') - if _is_cycode_command(command): + if _has_cycode_command_field(entry): return True for hook in entry.get('hooks', []): @@ -47,9 +54,9 @@ def _strip_cycode_from_entry(entry: dict) -> Optional[dict]: every nested hook was Cycode). Non-Cycode hooks co-located in the same entry are preserved. """ - # Cursor format: the entry itself IS a single hook command. - if 'command' in entry and 'hooks' not in entry: - return None if _is_cycode_command(entry.get('command', '')) else entry + # Cursor/Copilot format: the entry itself IS a single hook command. + if 'hooks' not in entry and any(field in entry for field in _COMMAND_FIELDS): + return None if _has_cycode_command_field(entry) else entry # Claude Code / Codex format: nested `hooks` list inside the entry. nested = entry.get('hooks') diff --git a/cycode/cli/apps/ai_guardrails/ides/__init__.py b/cycode/cli/apps/ai_guardrails/ides/__init__.py index 127431ef..396074da 100644 --- a/cycode/cli/apps/ai_guardrails/ides/__init__.py +++ b/cycode/cli/apps/ai_guardrails/ides/__init__.py @@ -10,11 +10,12 @@ from cycode.cli.apps.ai_guardrails.ides.base import IDE from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode from cycode.cli.apps.ai_guardrails.ides.codex import Codex +from cycode.cli.apps.ai_guardrails.ides.copilot import Copilot from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor # Single source of truth: name → singleton instance. # `--ide` choices and install/uninstall/status iteration both derive from this. -IDES: dict[str, IDE] = {ide.name: ide for ide in (Cursor(), ClaudeCode(), Codex())} +IDES: dict[str, IDE] = {ide.name: ide for ide in (Cursor(), ClaudeCode(), Codex(), Copilot())} # Default IDE used when `--ide` is omitted. Kept here so the value is colocated # with the registry; no module outside `ides/` needs to know which IDE wins. diff --git a/cycode/cli/apps/ai_guardrails/ides/base.py b/cycode/cli/apps/ai_guardrails/ides/base.py index 28971db9..84e4315f 100644 --- a/cycode/cli/apps/ai_guardrails/ides/base.py +++ b/cycode/cli/apps/ai_guardrails/ides/base.py @@ -14,6 +14,7 @@ JSON response shape that the IDE expects on stdout. """ +import platform from abc import ABC, abstractmethod from dataclasses import dataclass from enum import Enum @@ -24,6 +25,24 @@ from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType +def shell_background_suffix(async_mode: bool) -> str: + """`' &'` when backgrounding is requested and the platform's shell supports it. + + Only valid for hooks whose runner is stdin-safe under backgrounding (zsh keeps + a backgrounded command's stdin; verified for Cursor/Codex). bash/sh reattach it + to /dev/null, silently emptying the payload — hooks that run under bash (e.g. + Copilot's `bash` field) must add an explicit `<&0` redirect instead. + + Windows gets no suffix: depending on the IDE, hooks may run under cmd (where a + trailing `&` is a no-op separator) or Windows PowerShell (where it's a parse + error that would fail the hook). Until the CLI can self-detach in report mode, + Windows hooks run synchronously. + """ + if not async_mode or platform.system() == 'Windows': + return '' + return ' &' + + class DecisionAction(str, Enum): """Canonical decision action returned by event handlers.""" diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py index 17e7563d..f48794ef 100644 --- a/cycode/cli/apps/ai_guardrails/ides/claude_code.py +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -278,7 +278,11 @@ def render_hooks_config(self, async_mode: bool = False) -> dict: } def matches_payload(self, raw_payload: dict) -> bool: - return raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES + # transcript_path is a documented Claude Code common field, present on every + # hook event. VS Code Copilot emits near-identical payloads (same event names, + # snake_case fields) without it — requiring it keeps those from being + # processed as Claude Code events. + return raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES and 'transcript_path' in raw_payload def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: hook_event_name = raw_payload.get('hook_event_name', '') diff --git a/cycode/cli/apps/ai_guardrails/ides/codex.py b/cycode/cli/apps/ai_guardrails/ides/codex.py index e8049621..c9e48393 100644 --- a/cycode/cli/apps/ai_guardrails/ides/codex.py +++ b/cycode/cli/apps/ai_guardrails/ides/codex.py @@ -20,7 +20,7 @@ resolve_cached_plugin_dir, walk_enabled_plugins, ) -from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision, shell_background_suffix from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType from cycode.cli.utils.jwt_utils import decode_jwt_unverified @@ -191,10 +191,9 @@ def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: def render_hooks_config(self, async_mode: bool = False) -> dict: # Codex's TOML `async: true` flag is unimplemented; shell-background via - # `&` is the working mechanism. SessionStart stays sync so the - # conversation context is registered before any scan hook fires. - bg = ' &' if async_mode else '' - scan_cmd = f'{_SCAN_COMMAND}{bg}' + # `&` is the working mechanism (unix only). SessionStart stays sync so + # the conversation context is registered before any scan hook fires. + scan_cmd = f'{_SCAN_COMMAND}{shell_background_suffix(async_mode)}' return { 'hooks': { 'SessionStart': [ diff --git a/cycode/cli/apps/ai_guardrails/ides/copilot.py b/cycode/cli/apps/ai_guardrails/ides/copilot.py new file mode 100644 index 00000000..12ef8f89 --- /dev/null +++ b/cycode/cli/apps/ai_guardrails/ides/copilot.py @@ -0,0 +1,436 @@ +"""GitHub Copilot (VS Code extension) integration for AI guardrails. + +Hooks are installed in Copilot's native format to ``~/.copilot/hooks/cycode.json`` +(user scope) or ``/.github/hooks/cycode.json`` (repo scope). Both locations +are also read by Copilot CLI and the Copilot cloud coding agent, but only the +VS Code payload dialect is parsed here — CLI payloads (camelCase, no event name) +are rejected by ``matches_payload`` and fall through to the allow-and-skip path. + +VS Code sends Claude-style payloads (``hook_event_name``, ``tool_name``, +``tool_input``) with structural differences that ``matches_payload`` keys on: +a top-level ISO ``timestamp`` and no ``transcript_path``. Copilot hooks have no +matchers, so ``preToolUse`` fires for every tool; tools we don't scan pass +through as raw event names, which match no handler and allow immediately. +""" + +import json +import os +import platform +import re +from collections.abc import Iterable +from pathlib import Path +from typing import ClassVar, Optional, Union +from urllib.parse import urlparse +from urllib.request import url2pathname + +from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND +from cycode.cli.apps.ai_guardrails.ides._plugin_utils import ( + build_global_config_file, + load_plugin_json, + walk_enabled_plugins, +) +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision +from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType +from cycode.logger import get_logger + +logger = get_logger('AI Guardrails Copilot') + +# Payload dialect (VS Code sends Claude-style PascalCase event names). +_COPILOT_SCAN_EVENT_NAMES = frozenset({'UserPromptSubmit', 'PreToolUse'}) +_READ_FILE_TOOL = 'read_file' +# VS Code names MCP tools `mcp__` (single underscores). +_MCP_TOOL_PREFIX = 'mcp_' + +# Hooks-file dialect (Copilot-native camelCase event names). +_HOOK_EVENTS = ['userPromptSubmitted', 'preToolUse'] + +_COPILOT_HOME_ENV_VAR = 'COPILOT_HOME' +_HOOKS_FILE_NAME = 'cycode.json' +_REPO_HOOKS_SUBDIR = Path('.github') / 'hooks' +_HOOK_TIMEOUT_SEC = 20 +_MCP_CONFIG_FILENAME = 'mcp.json' + +# Plugin sources. CLI installs register in ~/.copilot/config.json and auto-surface +# in VS Code; VS Code UI installs register in ~/.vscode/agent-plugins/installed.json; +# local-directory plugins are declared via the chat.pluginLocations setting. +_VSCODE_PLUGINS_REGISTRY_NAME = 'installed.json' +_PLUGIN_LOCATIONS_SETTING = 'chat.pluginLocations' +_LOCAL_PLUGINS_MARKETPLACE = 'local' + +# Manifest locations in VS Code's documented detection order. Plugins may ship +# several manifest dialects at once — first hit wins, matching VS Code's probing. +_PLUGIN_MANIFEST_LOCATIONS = ( + Path('.plugin') / 'plugin.json', + Path('plugin.json'), + Path('.github') / 'plugin' / 'plugin.json', + Path('.claude-plugin') / 'plugin.json', +) + +# --event is ignored by the VS Code payload parsing (the payload self-describes) +# but Copilot CLI payloads carry no event name at all — baking the flag in now +# means CLI support won't require customers to re-install hooks. Values use the +# payload-dialect spelling so a future CLI path can inject them straight into +# hook_event_name and reuse the existing parsing. +_SCAN_PROMPT_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide copilot --event UserPromptSubmit' +_SCAN_TOOL_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide copilot --event PreToolUse' +_SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide copilot' + + +def _copilot_home() -> Path: + """Resolve Copilot's user-scope home directory (honors ``$COPILOT_HOME``).""" + override = os.environ.get(_COPILOT_HOME_ENV_VAR) + if override: + return Path(override) + return Path.home() / '.copilot' + + +def _vscode_agent_plugins_dir() -> Path: + # Resolved at call time (not a module-level Path constant): on py<=3.10 a Path + # instance binds its filesystem accessor at creation, which breaks fake-fs tests + # and ignores home changes. + return Path.home() / '.vscode' / 'agent-plugins' + + +def _vscode_user_dir() -> Path: + """Per-platform VS Code user settings directory.""" + if platform.system() == 'Darwin': + return Path.home() / 'Library' / 'Application Support' / 'Code' / 'User' + if platform.system() == 'Windows': + return Path.home() / 'AppData' / 'Roaming' / 'Code' / 'User' + return Path.home() / '.config' / 'Code' / 'User' + + +def _vscode_mcp_config_path() -> Path: + return _vscode_user_dir() / _MCP_CONFIG_FILENAME + + +def _load_vscode_mcp_config(config_path: Optional[Path] = None) -> Optional[dict]: + """Load and parse VS Code's user-level ``mcp.json``. Returns None if missing/invalid.""" + path = config_path or _vscode_mcp_config_path() + if not path.exists(): + logger.debug('VS Code MCP config file not found, %s', {'path': str(path)}) + return None + try: + return json.loads(path.read_text(encoding='utf-8')) + except Exception as e: + logger.debug('Failed to load VS Code MCP config file', exc_info=e) + return None + + +def _load_jsonc(path: Path) -> Optional[dict]: + """Parse a JSON file tolerating //-comment lines (Copilot's config.json ships + with a comment header; VS Code's settings.json is JSONC). + + Best-effort: JSONC constructs beyond full-line comments (trailing commas, + inline comments) read as a missing file. + """ + if not path.exists(): + logger.debug('Config file not found, %s', {'path': str(path)}) + return None + try: + text = path.read_text(encoding='utf-8') + stripped = '\n'.join(line for line in text.splitlines() if not line.lstrip().startswith('//')) + return json.loads(stripped) + except Exception as e: + logger.debug('Failed to load config file, %s', {'path': str(path)}, exc_info=e) + return None + + +# --- plugins inventory ---------------------------------------------------------- + + +def _read_copilot_plugin(plugin_dir: Path) -> tuple[dict, dict]: + """Read one Copilot plugin's manifest + MCP servers. + + The manifest's ``mcpServers`` field, when present, is a path string to the MCP + file; otherwise the root ``.mcp.json`` convention applies (same as Claude + plugins). Both forms exist in marketplace plugins. + """ + manifest: dict = {} + for location in _PLUGIN_MANIFEST_LOCATIONS: + manifest = load_plugin_json(plugin_dir / location) or {} + if manifest: + break + + entry: dict = {} + for field in ('name', 'version', 'description'): + if field in manifest: + entry[field] = manifest[field] + + mcp_ref = manifest.get('mcpServers') + mcp_config_path = plugin_dir / mcp_ref if isinstance(mcp_ref, str) else plugin_dir / '.mcp.json' + mcp_doc = load_plugin_json(mcp_config_path) or {} + servers = mcp_doc.get('mcpServers') + if not isinstance(servers, dict): + servers = {} + if servers: + entry['mcp_server_names'] = list(servers.keys()) + entry['mcp_config_file_path'] = str(mcp_config_path) + entry['mcp_config_file'] = json.dumps({'mcpServers': servers}) + return entry, servers + + +def _walk_registry_plugins(entries: dict[str, dict], dirs: dict[str, Path], is_enabled: bool = True) -> dict: + """Walk plugins whose directories are known up front (registry-provided).""" + return walk_enabled_plugins( + plugin_entries=entries, + is_enabled=lambda p: p.get('enabled', True) if is_enabled else True, + locate_dir=lambda name, marketplace: dirs.get(f'{name}@{marketplace}'), + read_plugin=_read_copilot_plugin, + ) + + +def _cli_registry_plugins() -> dict: + """Plugins installed via Copilot CLI: ``~/.copilot/config.json`` → ``installedPlugins``.""" + config = _load_jsonc(_copilot_home() / 'config.json') or {} + entries: dict[str, dict] = {} + dirs: dict[str, Path] = {} + for plugin in config.get('installedPlugins') or []: + if not isinstance(plugin, dict) or not plugin.get('name'): + continue + key = f'{plugin["name"]}@{plugin.get("marketplace", "")}' + entries[key] = plugin + if plugin.get('cache_path'): + dirs[key] = Path(plugin['cache_path']) + return _walk_registry_plugins(entries, dirs) + + +def _vscode_registry_plugins() -> dict: + """Plugins installed via the VS Code UI (@agentPlugins): ``~/.vscode/agent-plugins/installed.json``. + + Registry-driven only — the directory also holds marketplace clones that are + not installed. ``pluginUri`` is the authoritative location (the registry's + ``marketplace`` label is unreliable); presence in the registry means enabled. + """ + registry = load_plugin_json(_vscode_agent_plugins_dir() / _VSCODE_PLUGINS_REGISTRY_NAME) or {} + entries: dict[str, dict] = {} + dirs: dict[str, Path] = {} + for plugin in registry.get('installed') or []: + if not isinstance(plugin, dict) or not plugin.get('name'): + continue + key = f'{plugin["name"]}@{plugin.get("marketplace", "")}' + entries[key] = plugin + uri = plugin.get('pluginUri', '') + if uri.startswith('file://'): + # url2pathname unquotes and handles Windows drive-letter URIs (file:///C:/...). + dirs[key] = Path(url2pathname(urlparse(uri).path)) + return _walk_registry_plugins(entries, dirs, is_enabled=False) + + +def _local_dir_plugins() -> dict: + """Local-directory plugins declared via the ``chat.pluginLocations`` setting.""" + settings = _load_jsonc(_vscode_user_dir() / 'settings.json') or {} + locations = settings.get(_PLUGIN_LOCATIONS_SETTING) + if not isinstance(locations, dict): + return {} + entries: dict[str, bool] = {} + dirs: dict[str, Path] = {} + for raw_path, enabled in locations.items(): + path = Path(raw_path).expanduser() + key = f'{path.name}@{_LOCAL_PLUGINS_MARKETPLACE}' + entries[key] = bool(enabled) + dirs[key] = path + return walk_enabled_plugins( + plugin_entries=entries, + is_enabled=bool, + locate_dir=lambda name, marketplace: dirs.get(f'{name}@{marketplace}'), + read_plugin=_read_copilot_plugin, + ) + + +def _collect_installed_plugins() -> dict: + """Merge the three plugin sources (first source wins on a duplicate key).""" + plugins: dict = {} + for source in (_cli_registry_plugins, _vscode_registry_plugins, _local_dir_plugins): + for key, entry in source().items(): + plugins.setdefault(key, entry) + return plugins + + +# --- MCP tool-name splitting ------------------------------------------------------ + + +def _known_mcp_server_names() -> list[str]: + """Config-declared MCP server names: user-level ``mcp.json`` + plugin configs. + + Best-effort inventory: servers contributed by extensions, ``chat.mcp.discovery`` + imports, dev containers, or non-default profiles are not discoverable from disk. + """ + config = _load_vscode_mcp_config() + servers = (config or {}).get('servers') + names = list(servers.keys()) if isinstance(servers, dict) else [] + for plugin in _collect_installed_plugins().values(): + names.extend(plugin.get('mcp_server_names') or []) + return names + + +def _server_name_variants(server_name: str) -> set[str]: + """Normalized forms a config name may take inside a VS Code tool-name prefix. + + The prefix derives from the server's self-reported handshake name, which often + resembles the config name modulo case and separators (a server configured as + ``dummy-tracker`` self-reporting ``DummyTracker`` yields prefix ``dummytracker``). + """ + lowered = server_name.lower() + underscored = re.sub(r'[^a-z0-9]+', '_', lowered).strip('_') + collapsed = re.sub(r'[^a-z0-9]', '', lowered) + return {v for v in (server_name, underscored, collapsed) if v} + + +def split_mcp_tool_name(tool_name: str, server_names: Iterable[str]) -> tuple[Optional[str], Optional[str]]: + """Split ``mcp__`` into ``(server, tool)``. + + The ```` part is VS Code's sanitized (and possibly truncated) form of + the server's SELF-REPORTED handshake name, not the config key — so matching + against known config names (and their normalized variants) is best-effort. + When nothing matches, return the unsplit remainder as the tool rather than + fabricating a server from a guessed split. + """ + rest = tool_name[len(_MCP_TOOL_PREFIX) :] + + best_server = None + best_variant_len = -1 + for server in server_names: + for variant in _server_name_variants(server): + if (rest == variant or rest.startswith(f'{variant}_')) and len(variant) > best_variant_len: + best_server = server + best_variant_len = len(variant) + if best_server is not None: + return best_server, rest[best_variant_len + 1 :] or None + + return None, rest or None + + +class Copilot(IDE): + name: ClassVar[str] = 'copilot' + display_name: ClassVar[str] = 'GitHub Copilot' + hook_events: ClassVar[list[str]] = list(_HOOK_EVENTS) + + def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: + # Dedicated Cycode-owned file (Copilot reads every *.json in the hooks + # dir), unlike the shared settings files of other IDEs. + if scope == 'repo' and repo_path: + return repo_path / _REPO_HOOKS_SUBDIR / _HOOKS_FILE_NAME + return _copilot_home() / 'hooks' / _HOOKS_FILE_NAME + + def render_hooks_config(self, async_mode: bool = False) -> dict: + def entry(command: str) -> dict: + if async_mode: + # Copilot has no async hook flag; background via shell on unix. The + # explicit <&0 keeps the payload flowing: a bare `cmd &` gets its stdin + # reattached to /dev/null by the shell (job control is off in hooks). + # Windows PowerShell has no trailing-& operator, so it stays sync. + return { + 'type': 'command', + 'bash': f'{command} <&0 &', + 'powershell': command, + 'timeoutSec': _HOOK_TIMEOUT_SEC, + } + # Single cross-platform `command` field, copied to both shells by Copilot. + return {'type': 'command', 'command': command, 'timeoutSec': _HOOK_TIMEOUT_SEC} + + return { + 'version': 1, + 'hooks': { + 'sessionStart': [ + {'type': 'command', 'command': _SESSION_START_COMMAND, 'timeoutSec': _HOOK_TIMEOUT_SEC} + ], + 'userPromptSubmitted': [entry(_SCAN_PROMPT_COMMAND)], + 'preToolUse': [entry(_SCAN_TOOL_COMMAND)], + }, + } + + def matches_payload(self, raw_payload: dict) -> bool: + # Structural discrimination, no magic strings: VS Code Copilot events carry + # a top-level ISO timestamp and no transcript_path; real Claude Code events + # always carry transcript_path; Copilot CLI payloads have no hook_event_name. + return ( + raw_payload.get('hook_event_name', '') in _COPILOT_SCAN_EVENT_NAMES + and 'timestamp' in raw_payload + and 'transcript_path' not in raw_payload + ) + + def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: + hook_event_name = raw_payload.get('hook_event_name', '') + tool_name = raw_payload.get('tool_name', '') + tool_input = raw_payload.get('tool_input') + + if hook_event_name == 'UserPromptSubmit': + canonical_event: Union[AiHookEventType, str] = AiHookEventType.PROMPT + elif hook_event_name == 'PreToolUse' and tool_name == _READ_FILE_TOOL: + canonical_event = AiHookEventType.FILE_READ + elif hook_event_name == 'PreToolUse' and tool_name.startswith(_MCP_TOOL_PREFIX): + canonical_event = AiHookEventType.MCP_EXECUTION + else: + # No matchers in Copilot hooks: preToolUse fires for every tool. Pass + # the raw tool name through — it matches no handler, so scan_command + # answers with a neutral allow before any policy/network work. + canonical_event = tool_name or hook_event_name + + file_path = None + if canonical_event == AiHookEventType.FILE_READ and isinstance(tool_input, dict): + file_path = tool_input.get('filePath') + + mcp_server_name = None + mcp_tool_name = None + mcp_arguments = None + if canonical_event == AiHookEventType.MCP_EXECUTION: + mcp_server_name, mcp_tool_name = split_mcp_tool_name(tool_name, _known_mcp_server_names()) + mcp_arguments = tool_input if isinstance(tool_input, dict) else None + + return AIHookPayload( + event_name=canonical_event, + conversation_id=raw_payload.get('session_id'), + ide_provider=self.name, + prompt=raw_payload.get('prompt', ''), + file_path=file_path, + mcp_server_name=mcp_server_name, + mcp_tool_name=mcp_tool_name, + mcp_arguments=mcp_arguments, + ) + + def build_hook_response(self, decision: HookDecision) -> dict: + if decision.action == DecisionAction.ALLOW: + # Neutral allow: {} means "no objection", leaving VS Code's own + # permission flow intact. An explicit permissionDecision "allow" would + # pre-approve the tool past the user's confirmation prompts — and with + # no matchers that would cover every tool, not just scanned ones. + return {} + + if decision.event_type == AiHookEventType.PROMPT: + reason = decision.user_message or '' + # decision/reason is what VS Code acts on; continue/stopReason/systemMessage + # are the generic top-level fields — the combo is what was verified live. + return { + 'decision': 'block', + 'reason': reason, + 'continue': False, + 'stopReason': reason, + 'systemMessage': reason, + } + + return { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': decision.action.value, # 'deny' or 'ask' + 'permissionDecisionReason': decision.user_message or '', + } + } + + def build_session_payload(self, raw_payload: dict) -> AIHookPayload: + return AIHookPayload( + conversation_id=raw_payload.get('session_id'), + model=raw_payload.get('model'), + ide_provider=self.name, + source=raw_payload.get('source'), + ) + + def get_session_context(self) -> tuple[Optional[dict], dict]: + # VS Code's mcp.json uses `servers` as its top-level key; normalized to the + # canonical mcpServers shape by build_global_config_file. + config = _load_vscode_mcp_config() + global_config_file = ( + build_global_config_file(_vscode_mcp_config_path(), config.get('servers')) if config else None + ) + return global_config_file, _collect_installed_plugins() diff --git a/cycode/cli/apps/ai_guardrails/ides/cursor.py b/cycode/cli/apps/ai_guardrails/ides/cursor.py index 950e15bb..01c65edb 100644 --- a/cycode/cli/apps/ai_guardrails/ides/cursor.py +++ b/cycode/cli/apps/ai_guardrails/ides/cursor.py @@ -7,7 +7,7 @@ from cycode.cli.apps.ai_guardrails.consts import CYCODE_SCAN_PROMPT_COMMAND, CYCODE_SESSION_START_COMMAND from cycode.cli.apps.ai_guardrails.ides._plugin_utils import build_global_config_file -from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision +from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision, shell_background_suffix from cycode.cli.apps.ai_guardrails.scan.payload import AIHookPayload from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType from cycode.logger import get_logger @@ -69,7 +69,7 @@ def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: return _user_hooks_dir() / _HOOKS_FILE_NAME def render_hooks_config(self, async_mode: bool = False) -> dict: - command = f'{_SCAN_COMMAND} &' if async_mode else _SCAN_COMMAND + command = f'{_SCAN_COMMAND}{shell_background_suffix(async_mode)}' hooks = {event: [{'command': command}] for event in self.hook_events} hooks['sessionStart'] = [{'command': _SESSION_START_COMMAND}] return {'version': 1, 'hooks': hooks} diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index e6f8b977..cad92263 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -82,6 +82,14 @@ def scan_command( hidden=True, ), ] = DEFAULT_IDE_NAME, + event: Annotated[ + Optional[str], + typer.Option( + '--event', + help='Hook event that triggered the scan, for IDEs whose payloads omit it (e.g. Copilot CLI).', + hidden=True, + ), + ] = None, ) -> None: """Scan content from AI IDE hooks for secrets. @@ -110,7 +118,18 @@ def scan_command( unified_payload = ide_integration.parse_hook_payload(payload) event_name = unified_payload.event_name - logger.debug('Processing AI guardrails hook', extra={'event_name': event_name, 'ide': ide_integration.name}) + logger.debug( + 'Processing AI guardrails hook', + extra={'event_name': event_name, 'ide': ide_integration.name, 'cli_event_hint': event}, + ) + + # Resolved before any policy/client work: Copilot hooks have no matchers, so + # every tool call arrives here and unmatched tools must exit fast. + 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(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) + return # `or` (not a .get default) - Cursor sends workspace_roots=[] when no folder is open. workspace_roots = payload.get('workspace_roots') or ['.'] @@ -119,12 +138,6 @@ def scan_command( 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(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) - return - decision = handler(ctx, unified_payload, policy) logger.debug('Hook handler completed', extra={'event_name': event_name, 'action': decision.action.value}) output_json(ide_integration.build_hook_response(decision)) diff --git a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py index dbaca44f..60fb331e 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py +++ b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py @@ -20,10 +20,34 @@ def test_matches_payload_only_claude_events() -> None: claude = ClaudeCode() - assert claude.matches_payload({'hook_event_name': 'UserPromptSubmit'}) is True - assert claude.matches_payload({'hook_event_name': 'PreToolUse'}) is True - assert claude.matches_payload({'hook_event_name': 'beforeSubmitPrompt'}) is False - assert claude.matches_payload({'hook_event_name': 'beforeReadFile'}) is False + transcript = {'transcript_path': '/home/user/.claude/projects/transcript.jsonl'} + assert claude.matches_payload({'hook_event_name': 'UserPromptSubmit', **transcript}) is True + assert claude.matches_payload({'hook_event_name': 'PreToolUse', **transcript}) is True + assert claude.matches_payload({'hook_event_name': 'beforeSubmitPrompt', **transcript}) is False + assert claude.matches_payload({'hook_event_name': 'beforeReadFile', **transcript}) is False + + +def test_matches_payload_rejects_vscode_copilot_payloads() -> None: + """VS Code Copilot sends the same event names in the same snake_case dialect, + but never a transcript_path — those events must not be claimed as Claude Code.""" + claude = ClaudeCode() + assert ( + claude.matches_payload( + { + 'timestamp': '2026-07-14T13:33:24.387Z', + 'hook_event_name': 'PreToolUse', + 'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2', + 'tool_name': 'read_file', + 'tool_input': {'filePath': '/Users/user/.gitconfig'}, + 'tool_use_id': 'call_KuiUJvNJ06uHlIdwKy16G9W6__vscode-1784034535752', + } + ) + is False + ) + assert ( + claude.matches_payload({'timestamp': '2026-07-14T13:32:46.517Z', 'hook_event_name': 'UserPromptSubmit'}) + is False + ) def test_parse_prompt_payload() -> None: diff --git a/tests/cli/commands/ai_guardrails/ides/test_codex.py b/tests/cli/commands/ai_guardrails/ides/test_codex.py index 682739e6..33137311 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_codex.py +++ b/tests/cli/commands/ai_guardrails/ides/test_codex.py @@ -8,6 +8,7 @@ import pytest from pyfakefs.fake_filesystem import FakeFilesystem +from pytest_mock import MockerFixture from cycode.cli.apps.ai_guardrails.ides.base import HookDecision from cycode.cli.apps.ai_guardrails.ides.codex import ( @@ -165,8 +166,9 @@ def test_render_hooks_never_emits_async_toml_flags() -> None: assert 'timeout' not in hook -def test_render_hooks_async_backgrounds_scan_hooks() -> None: - """In async mode, UserPromptSubmit + PreToolUse scan hooks shell-background.""" +def test_render_hooks_async_backgrounds_scan_hooks(mocker: MockerFixture) -> None: + """In async mode, UserPromptSubmit + PreToolUse scan hooks shell-background (unix).""" + mocker.patch('platform.system', return_value='Linux') rendered = Codex().render_hooks_config(async_mode=True) prompt_cmd = rendered['hooks']['UserPromptSubmit'][0]['hooks'][0]['command'] pretool_cmd = rendered['hooks']['PreToolUse'][0]['hooks'][0]['command'] @@ -174,6 +176,16 @@ def test_render_hooks_async_backgrounds_scan_hooks() -> None: assert pretool_cmd.endswith(' &') +def test_render_hooks_async_windows_stays_sync(mocker: MockerFixture) -> None: + """No '&' on Windows - nothing there detaches safely, so hooks run sync.""" + mocker.patch('platform.system', return_value='Windows') + rendered = Codex().render_hooks_config(async_mode=True) + prompt_cmd = rendered['hooks']['UserPromptSubmit'][0]['hooks'][0]['command'] + pretool_cmd = rendered['hooks']['PreToolUse'][0]['hooks'][0]['command'] + assert '&' not in prompt_cmd + assert '&' not in pretool_cmd + + def test_render_hooks_session_start_always_synchronous() -> None: """SessionStart registers the conversation context — never backgrounded.""" for mode in (False, True): diff --git a/tests/cli/commands/ai_guardrails/ides/test_contract.py b/tests/cli/commands/ai_guardrails/ides/test_contract.py index 3bdbc19d..7d7ab773 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_contract.py +++ b/tests/cli/commands/ai_guardrails/ides/test_contract.py @@ -8,6 +8,7 @@ from pathlib import Path import pytest +from pytest_mock import MockerFixture from cycode.cli.apps.ai_guardrails.ides import IDES from cycode.cli.apps.ai_guardrails.ides.base import IDE, DecisionAction, HookDecision @@ -60,8 +61,13 @@ def test_render_hooks_config_has_hooks_key(ide: IDE) -> None: assert isinstance(rendered['hooks'], dict) -def test_render_hooks_config_async_changes_output(ide: IDE) -> None: - """async_mode must influence the rendered output.""" +def test_render_hooks_config_async_changes_output(ide: IDE, mocker: MockerFixture) -> None: + """async_mode must influence the rendered output. + + Pinned to a unix platform: IDEs that background via a shell `&` render + identical sync/async configs on Windows, where no safe suffix exists. + """ + mocker.patch('platform.system', return_value='Linux') assert ide.render_hooks_config(async_mode=False) != ide.render_hooks_config(async_mode=True) diff --git a/tests/cli/commands/ai_guardrails/ides/test_copilot.py b/tests/cli/commands/ai_guardrails/ides/test_copilot.py new file mode 100644 index 00000000..8141f74f --- /dev/null +++ b/tests/cli/commands/ai_guardrails/ides/test_copilot.py @@ -0,0 +1,416 @@ +"""GitHub Copilot (VS Code) IDE integration tests. + +Payload fixtures mirror real events captured from VS Code (built-in Copilot +Chat 0.56.0) and Copilot CLI, with identifying values swapped for dummies. +""" + +import json +import os +from pathlib import Path +from typing import Optional + +from pyfakefs.fake_filesystem import FakeFilesystem +from pytest_mock import MockerFixture + +from cycode.cli.apps.ai_guardrails.ides.base import HookDecision +from cycode.cli.apps.ai_guardrails.ides.copilot import ( + Copilot, + _vscode_mcp_config_path, + split_mcp_tool_name, +) +from cycode.cli.apps.ai_guardrails.scan.types import AiHookEventType + +_VSCODE_PROMPT_PAYLOAD = { + 'timestamp': '2026-07-14T13:32:46.517Z', + 'hook_event_name': 'UserPromptSubmit', + 'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2', + 'prompt': 'test prompt', +} + +_VSCODE_READ_FILE_PAYLOAD = { + 'timestamp': '2026-07-14T13:35:08.758Z', + 'hook_event_name': 'PreToolUse', + 'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2', + 'tool_name': 'read_file', + 'tool_input': {'filePath': '/Users/user/.gitconfig', 'startLine': 1, 'endLine': 200}, + 'tool_use_id': 'call_dummyDummyDummyDummy__vscode-1784034535752', +} + +_VSCODE_MCP_PAYLOAD = { + 'timestamp': '2026-07-14T14:03:57.337Z', + 'hook_event_name': 'PreToolUse', + 'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2', + 'tool_name': 'mcp_gitlab_get_user', + 'tool_input': {'user_id': 'dummy-user'}, + 'tool_use_id': 'call_dummyDummyDummyDummy__vscode-1784034535755', +} + +_VSCODE_SESSION_START_PAYLOAD = { + 'timestamp': '2026-07-14T13:32:46.474Z', + 'hook_event_name': 'SessionStart', + 'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2', + 'source': 'new', + 'model': 'auto', +} + +# Copilot CLI dialect: camelCase, epoch-ms timestamp, no event name, stringified args. +_COPILOT_CLI_TOOL_PAYLOAD = { + 'sessionId': '826a14c1-cfb5-4946-9618-8b0bb7060466', + 'timestamp': 1784038775604, + 'cwd': '/Users/user', + 'toolName': 'view', + 'toolArgs': '{"path": "/Users/user/.zshrc"}', +} + +_CLAUDE_CODE_PAYLOAD = { + 'session_id': 'session-123', + 'transcript_path': '/home/user/.claude/projects/transcript.jsonl', + 'cwd': '/Users/user/project', + 'hook_event_name': 'PreToolUse', + 'tool_name': 'Read', + 'tool_input': {'file_path': '/Users/user/.gitconfig'}, + 'tool_use_id': 'toolu_dummyDummyDummyDummy', +} + + +# --- matches_payload ------------------------------------------------------------ + + +def test_matches_payload_accepts_vscode_events() -> None: + copilot = Copilot() + assert copilot.matches_payload(_VSCODE_PROMPT_PAYLOAD) is True + assert copilot.matches_payload(_VSCODE_READ_FILE_PAYLOAD) is True + assert copilot.matches_payload(_VSCODE_MCP_PAYLOAD) is True + + +def test_matches_payload_rejects_claude_code_payloads() -> None: + # Same event names and dialect, but Claude Code always carries transcript_path. + assert Copilot().matches_payload(_CLAUDE_CODE_PAYLOAD) is False + + +def test_matches_payload_rejects_copilot_cli_payloads() -> None: + # CLI dialect is unsupported until its own parsing lands - must skip fail-open. + assert Copilot().matches_payload(_COPILOT_CLI_TOOL_PAYLOAD) is False + + +def test_matches_payload_rejects_cursor_payloads() -> None: + assert Copilot().matches_payload({'hook_event_name': 'beforeSubmitPrompt', 'prompt': 'test'}) is False + + +def test_matches_payload_requires_timestamp() -> None: + payload = {k: v for k, v in _VSCODE_PROMPT_PAYLOAD.items() if k != 'timestamp'} + assert Copilot().matches_payload(payload) is False + + +# --- parse_hook_payload --------------------------------------------------------- + + +def test_parse_prompt_payload() -> None: + unified = Copilot().parse_hook_payload(_VSCODE_PROMPT_PAYLOAD) + assert unified.event_name == AiHookEventType.PROMPT + assert unified.conversation_id == '43cbad91-ea8b-4d4a-9acc-56561421c5d2' + assert unified.ide_provider == 'copilot' + assert unified.prompt == 'test prompt' + + +def test_parse_read_file_payload() -> None: + unified = Copilot().parse_hook_payload(_VSCODE_READ_FILE_PAYLOAD) + assert unified.event_name == AiHookEventType.FILE_READ + assert unified.file_path == '/Users/user/.gitconfig' + assert unified.mcp_tool_name is None + + +def test_parse_mcp_payload_without_known_servers_reports_raw(fs: FakeFilesystem) -> None: + # No known servers on disk - honest fallback: no fabricated server, the full + # unsplit remainder as the tool. + unified = Copilot().parse_hook_payload(_VSCODE_MCP_PAYLOAD) + assert unified.event_name == AiHookEventType.MCP_EXECUTION + assert unified.mcp_server_name is None + assert unified.mcp_tool_name == 'gitlab_get_user' + assert unified.mcp_arguments == {'user_id': 'dummy-user'} + + +def test_parse_mcp_payload_with_known_server_containing_underscores(fs: FakeFilesystem) -> None: + fs.create_file( + _vscode_mcp_config_path(), + contents=json.dumps({'servers': {'gitlab_selfhosted': {'command': 'dummy-mcp'}}}), + ) + payload = {**_VSCODE_MCP_PAYLOAD, 'tool_name': 'mcp_gitlab_selfhosted_get_user'} + unified = Copilot().parse_hook_payload(payload) + assert unified.mcp_server_name == 'gitlab_selfhosted' + assert unified.mcp_tool_name == 'get_user' + + +def test_parse_unmatched_tool_passes_raw_tool_name_through() -> None: + # No matchers in Copilot hooks: unscanned tools must map to an event that + # matches no handler so scan_command answers with a neutral allow. + payload = {**_VSCODE_READ_FILE_PAYLOAD, 'tool_name': 'list_dir', 'tool_input': {'path': '/Users/user'}} + unified = Copilot().parse_hook_payload(payload) + assert unified.event_name == 'list_dir' + assert unified.file_path is None + assert unified.mcp_server_name is None + + +# --- split_mcp_tool_name -------------------------------------------------------- + + +def test_split_mcp_tool_name_prefers_longest_known_server() -> None: + servers = ['gitlab', 'gitlab_selfhosted'] + assert split_mcp_tool_name('mcp_gitlab_selfhosted_get_user', servers) == ('gitlab_selfhosted', 'get_user') + + +def test_split_mcp_tool_name_matches_normalized_config_name() -> None: + # The wire prefix is the sanitized SELF-REPORTED server name (`DummyTracker` -> + # `dummytracker`), which resembles the config name modulo separators. + assert split_mcp_tool_name('mcp_dummytracker_fetch_api', ['dummy-tracker']) == ('dummy-tracker', 'fetch_api') + + +def test_split_mcp_tool_name_unknown_server_reports_raw() -> None: + # Self-reported names can diverge entirely from config names (e.g. a server + # configured as `dummy-plugin` self-reporting `Vendor.DummyApp.Hybrid` yields + # a sanitized+truncated prefix like `vendor_du`) - never guess a split. + assert split_mcp_tool_name('mcp_vendor_du_search_instructions', ['dummy-plugin']) == ( + None, + 'vendor_du_search_instructions', + ) + + +def test_split_mcp_tool_name_server_only() -> None: + assert split_mcp_tool_name('mcp_gitlab', ['gitlab']) == ('gitlab', None) + + +# --- build_hook_response -------------------------------------------------------- + + +def test_allow_is_neutral_for_every_event_type() -> None: + """Allow must be {} - an explicit permissionDecision "allow" would pre-approve + tools past VS Code's own permission prompts (and with no matchers, that would + cover every tool, not just scanned ones).""" + copilot = Copilot() + for event_type in AiHookEventType: + assert copilot.build_hook_response(HookDecision.allow(event_type)) == {} + + +def test_deny_prompt_response_shape() -> None: + response = Copilot().build_hook_response(HookDecision.deny(AiHookEventType.PROMPT, 'Secrets detected')) + assert response['decision'] == 'block' + assert response['reason'] == 'Secrets detected' + assert response['continue'] is False + assert response['stopReason'] == 'Secrets detected' + + +def test_deny_tool_response_shape() -> None: + response = Copilot().build_hook_response(HookDecision.deny(AiHookEventType.FILE_READ, 'Sensitive file')) + assert response == { + 'hookSpecificOutput': { + 'hookEventName': 'PreToolUse', + 'permissionDecision': 'deny', + 'permissionDecisionReason': 'Sensitive file', + } + } + + +def test_ask_mcp_response_shape() -> None: + response = Copilot().build_hook_response(HookDecision.ask(AiHookEventType.MCP_EXECUTION, 'Allow execution?')) + assert response['hookSpecificOutput']['permissionDecision'] == 'ask' + assert response['hookSpecificOutput']['permissionDecisionReason'] == 'Allow execution?' + + +# --- render_hooks_config / settings_path ---------------------------------------- + + +def test_render_hooks_config_sync_uses_cross_platform_command() -> None: + rendered = Copilot().render_hooks_config() + assert rendered['version'] == 1 + + prompt_entry = rendered['hooks']['userPromptSubmitted'][0] + assert prompt_entry['command'] == 'cycode ai-guardrails scan --ide copilot --event UserPromptSubmit' + assert 'bash' not in prompt_entry + + tool_entry = rendered['hooks']['preToolUse'][0] + assert tool_entry['command'] == 'cycode ai-guardrails scan --ide copilot --event PreToolUse' + + session_entry = rendered['hooks']['sessionStart'][0] + assert session_entry['command'] == 'cycode ai-guardrails session-start --ide copilot' + + +def test_render_hooks_config_async_backgrounds_on_unix() -> None: + rendered = Copilot().render_hooks_config(async_mode=True) + tool_entry = rendered['hooks']['preToolUse'][0] + assert tool_entry['bash'].endswith('&') + assert not tool_entry['powershell'].endswith('&') + assert 'command' not in tool_entry + + +def test_settings_path_user_scope() -> None: + path = Copilot().settings_path('user') + assert path == Path.home() / '.copilot' / 'hooks' / 'cycode.json' + + +def test_settings_path_honors_copilot_home(mocker: MockerFixture) -> None: + mocker.patch.dict(os.environ, {'COPILOT_HOME': '/custom/copilot-home'}) + path = Copilot().settings_path('user') + assert path == Path('/custom/copilot-home') / 'hooks' / 'cycode.json' + + +def test_settings_path_repo_scope(tmp_path: Path) -> None: + path = Copilot().settings_path('repo', tmp_path) + assert path == tmp_path / '.github' / 'hooks' / 'cycode.json' + + +# --- session payload / context -------------------------------------------------- + + +def test_build_session_payload() -> None: + session = Copilot().build_session_payload(_VSCODE_SESSION_START_PAYLOAD) + assert session.ide_provider == 'copilot' + assert session.conversation_id == '43cbad91-ea8b-4d4a-9acc-56561421c5d2' + assert session.model == 'auto' + assert session.source == 'new' + + +def test_get_session_context_normalizes_servers_key(fs: FakeFilesystem) -> None: + config_path = _vscode_mcp_config_path() + fs.create_file( + config_path, + contents=json.dumps({'servers': {'gitlab': {'type': 'stdio', 'command': 'dummy-mcp'}}}), + ) + + global_config_file, plugins = Copilot().get_session_context() + + assert global_config_file is not None + assert global_config_file['path'] == str(config_path) + # VS Code's `servers` key is normalized to the canonical mcpServers shape. + assert json.loads(global_config_file['content']) == { + 'mcpServers': {'gitlab': {'type': 'stdio', 'command': 'dummy-mcp'}} + } + assert plugins == {} + + +def test_get_session_context_without_config(fs: FakeFilesystem) -> None: + assert Copilot().get_session_context() == (None, {}) + + +# --- plugins inventory ----------------------------------------------------------- + + +def _create_plugin_on_disk( + fs: FakeFilesystem, + plugin_dir: Path, + manifest_location: str = '.github/plugin/plugin.json', + manifest_extra: Optional[dict] = None, + mcp_file: str = '.mcp.json', + server_name: str = 'dummy-server', +) -> None: + manifest = {'name': plugin_dir.name, 'version': '1.0.0', 'description': 'Dummy plugin', **(manifest_extra or {})} + fs.create_file(plugin_dir / manifest_location, contents=json.dumps(manifest)) + fs.create_file( + plugin_dir / mcp_file, + contents=json.dumps({'mcpServers': {server_name: {'command': 'dummy-mcp'}}}), + ) + + +def test_cli_registry_plugins(fs: FakeFilesystem) -> None: + """CLI-installed plugins: comment-headed config.json registry, manifest with an + mcpServers path-ref.""" + plugin_dir = Path.home() / '.copilot' / 'installed-plugins' / 'dummy-marketplace' / 'dummy-plugin' + _create_plugin_on_disk(fs, plugin_dir, manifest_extra={'mcpServers': './.mcp.json'}) + fs.create_file( + Path.home() / '.copilot' / 'config.json', + contents='// This file is managed automatically.\n' + + json.dumps( + { + 'installedPlugins': [ + { + 'name': 'dummy-plugin', + 'marketplace': 'dummy-marketplace', + 'version': '1.0.0', + 'cache_path': str(plugin_dir), + 'enabled': True, + }, + { + 'name': 'disabled-plugin', + 'marketplace': 'dummy-marketplace', + 'cache_path': str(plugin_dir), + 'enabled': False, + }, + ] + } + ), + ) + + _, plugins = Copilot().get_session_context() + + assert set(plugins) == {'dummy-plugin@dummy-marketplace'} + entry = plugins['dummy-plugin@dummy-marketplace'] + assert entry['enabled'] is True + assert entry['version'] == '1.0.0' + assert entry['mcp_server_names'] == ['dummy-server'] + assert json.loads(entry['mcp_config_file']) == {'mcpServers': {'dummy-server': {'command': 'dummy-mcp'}}} + + +def test_vscode_registry_plugins(fs: FakeFilesystem) -> None: + """VS Code UI-installed plugins: installed.json registry with file:// pluginUri, + root .mcp.json convention without a manifest mcpServers field.""" + plugin_dir = ( + Path.home() / '.vscode' / 'agent-plugins' / 'github.com' / 'dummy-org' / 'repo' / 'plugins' / 'dummy-plugin' + ) + _create_plugin_on_disk(fs, plugin_dir, manifest_location='.claude-plugin/plugin.json') + fs.create_file( + Path.home() / '.vscode' / 'agent-plugins' / 'installed.json', + contents=json.dumps( + { + 'version': 1, + 'installed': [ + {'pluginUri': plugin_dir.as_uri(), 'marketplace': 'dummy-marketplace', 'name': 'dummy-plugin'} + ], + } + ), + ) + + _, plugins = Copilot().get_session_context() + + assert set(plugins) == {'dummy-plugin@dummy-marketplace'} + assert plugins['dummy-plugin@dummy-marketplace']['mcp_server_names'] == ['dummy-server'] + + +def test_local_dir_plugins_from_plugin_locations_setting(fs: FakeFilesystem) -> None: + """Local-directory plugins declared via chat.pluginLocations (JSONC settings).""" + enabled_dir = Path('/plugins/local-plugin') + disabled_dir = Path('/plugins/disabled-plugin') + _create_plugin_on_disk(fs, enabled_dir, manifest_location='plugin.json') + _create_plugin_on_disk(fs, disabled_dir, manifest_location='plugin.json') + # json.dumps escapes Windows path separators; the comment line exercises JSONC handling. + settings = json.dumps({'chat.pluginLocations': {str(enabled_dir): True, str(disabled_dir): False}}) + fs.create_file( + _vscode_mcp_config_path().parent / 'settings.json', + contents=f'// user settings\n{settings}', + ) + + _, plugins = Copilot().get_session_context() + + assert set(plugins) == {'local-plugin@local'} + assert plugins['local-plugin@local']['mcp_server_names'] == ['dummy-server'] + + +def test_parse_mcp_payload_matches_plugin_server_via_normalized_name(fs: FakeFilesystem) -> None: + """End-to-end split: a plugin-declared server named dummy-tracker attributes the + wire tool mcp_dummytracker_fetch_api (prefix = sanitized self-reported name).""" + plugin_dir = Path.home() / '.copilot' / 'installed-plugins' / 'dummy-marketplace' / 'dummy-tracker' + _create_plugin_on_disk(fs, plugin_dir, server_name='dummy-tracker') + fs.create_file( + Path.home() / '.copilot' / 'config.json', + contents=json.dumps( + { + 'installedPlugins': [ + {'name': 'dummy-tracker', 'marketplace': 'dummy-marketplace', 'cache_path': str(plugin_dir)} + ] + } + ), + ) + + payload = {**_VSCODE_MCP_PAYLOAD, 'tool_name': 'mcp_dummytracker_fetch_api', 'tool_input': {'key': 'payments'}} + unified = Copilot().parse_hook_payload(payload) + + assert unified.mcp_server_name == 'dummy-tracker' + assert unified.mcp_tool_name == 'fetch_api' 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 b7d7734a..349a1ee3 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py +++ b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py @@ -127,7 +127,12 @@ def test_claude_code_payload_with_claude_code_ide( 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'} + payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'test', + 'transcript_path': '/home/user/.claude/projects/transcript.jsonl', + } mocker.patch('sys.stdin', StringIO(json.dumps(payload))) mock_scan_command_deps['load_policy'].return_value = {'fail_open': True} @@ -166,6 +171,63 @@ def test_empty_workspace_roots_falls_back_to_cwd( mock_handler.assert_called_once() +class TestCopilotPayloadRouting: + """Copilot-specific routing through scan_command.""" + + def test_unmatched_tool_allows_without_policy_or_clients( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Copilot hooks have no matchers - tools we don't scan must skip fast. + + The handler lookup runs before load_policy/_initialize_clients, so an + unmatched tool costs neither file I/O nor network setup. + """ + payload = { + 'timestamp': '2026-07-14T13:33:24.387Z', + 'hook_event_name': 'PreToolUse', + 'session_id': 'session-123', + 'tool_name': 'list_dir', + 'tool_input': {'path': '/Users/user'}, + 'tool_use_id': 'call_abc__vscode-1', + } + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + mock_scan_command_deps['get_handler'].return_value = None + + scan_command(mock_ctx, ide='copilot') + + mock_scan_command_deps['get_handler'].assert_called_once_with('list_dir') + mock_scan_command_deps['load_policy'].assert_not_called() + mock_scan_command_deps['initialize_clients'].assert_not_called() + assert json.loads(capsys.readouterr().out) == {} + + def test_copilot_cli_payload_skipped( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Copilot CLI shares the hooks file but speaks camelCase without an event + name - until its dialect is supported, its events skip fail-open.""" + payload = { + 'sessionId': '826a14c1-cfb5-4946-9618-8b0bb7060466', + 'timestamp': 1784038775604, + 'cwd': '/Users/user', + 'toolName': 'view', + 'toolArgs': '{"path": "/Users/user/file"}', + } + mocker.patch('sys.stdin', StringIO(json.dumps(payload))) + + scan_command(mock_ctx, ide='copilot') + + _assert_no_api_calls(mock_scan_command_deps) + assert json.loads(capsys.readouterr().out) == {} + + class TestDefaultIdeParameterViaCli: """Tests that verify default IDE parameter works correctly via CLI invocation.""" diff --git a/tests/cli/commands/ai_guardrails/test_hooks_manager.py b/tests/cli/commands/ai_guardrails/test_hooks_manager.py index 2097780a..cf478fcc 100644 --- a/tests/cli/commands/ai_guardrails/test_hooks_manager.py +++ b/tests/cli/commands/ai_guardrails/test_hooks_manager.py @@ -1,5 +1,6 @@ """Tests for AI guardrails hooks manager and per-IDE hooks rendering.""" +import json from pathlib import Path from typing import TYPE_CHECKING @@ -8,6 +9,7 @@ if TYPE_CHECKING: import pytest + from pytest_mock import MockerFixture from cycode.cli.apps.ai_guardrails.consts import ( CYCODE_SCAN_PROMPT_COMMAND, @@ -22,6 +24,7 @@ ) from cycode.cli.apps.ai_guardrails.ides.claude_code import ClaudeCode from cycode.cli.apps.ai_guardrails.ides.codex import Codex +from cycode.cli.apps.ai_guardrails.ides.copilot import Copilot from cycode.cli.apps.ai_guardrails.ides.cursor import Cursor @@ -43,6 +46,13 @@ def test_is_cycode_hook_entry_claude_code_format() -> None: assert is_cycode_hook_entry(entry) is True +def test_is_cycode_hook_entry_copilot_shell_fields() -> None: + # Copilot async entries carry per-OS bash/powershell fields instead of `command`. + assert is_cycode_hook_entry({'type': 'command', 'bash': 'cycode ai-guardrails scan --ide copilot &'}) is True + assert is_cycode_hook_entry({'type': 'command', 'powershell': 'cycode ai-guardrails scan --ide copilot'}) is True + assert is_cycode_hook_entry({'type': 'command', 'bash': '/usr/local/bin/user-hook.sh'}) is False + + def test_is_cycode_hook_entry_non_cycode() -> None: """Non-Cycode hooks must not be detected.""" assert is_cycode_hook_entry({'command': 'some-other-command'}) is False @@ -69,8 +79,9 @@ def test_cursor_render_hooks_sync() -> None: assert '&' not in entry['command'] -def test_cursor_render_hooks_async() -> None: - """Cursor async hooks: '&' suffix on scan commands.""" +def test_cursor_render_hooks_async(mocker: 'MockerFixture') -> None: + """Cursor async hooks: '&' suffix on scan commands (unix).""" + mocker.patch('platform.system', return_value='Linux') config = Cursor().render_hooks_config(async_mode=True) scan_hooks = {k: v for k, v in config['hooks'].items() if k != 'sessionStart'} for entries in scan_hooks.values(): @@ -79,6 +90,17 @@ def test_cursor_render_hooks_async() -> None: assert CYCODE_SCAN_PROMPT_COMMAND in entry['command'] +def test_cursor_render_hooks_async_windows_stays_sync(mocker: 'MockerFixture') -> None: + """No '&' on Windows: cmd treats it as a no-op separator and Windows + PowerShell rejects it outright - either way nothing detaches.""" + mocker.patch('platform.system', return_value='Windows') + config = Cursor().render_hooks_config(async_mode=True) + scan_hooks = {k: v for k, v in config['hooks'].items() if k != 'sessionStart'} + for entries in scan_hooks.values(): + for entry in entries: + assert '&' not in entry['command'] + + def test_cursor_render_hooks_session_start() -> None: """Cursor session_start carries the --ide flag explicitly.""" config = Cursor().render_hooks_config() @@ -170,8 +192,6 @@ def test_install_preserves_user_hook_colocated_with_cycode( """install must not clobber a user-authored hook that shares an entry with a Cycode hook. The filter is hook-level, not entry-level. """ - import json - repo = Path('/repo') fs.create_dir(repo) hooks_path = repo / '.codex' / 'hooks.json' @@ -223,8 +243,6 @@ def test_uninstall_preserves_user_hook_colocated_with_cycode( fs: FakeFilesystem, monkeypatch: 'pytest.MonkeyPatch' ) -> None: """uninstall must strip only the Cycode hook from a mixed entry.""" - import json - repo = Path('/repo') fs.create_dir(repo) hooks_path = repo / '.codex' / 'hooks.json' @@ -259,6 +277,32 @@ def test_uninstall_preserves_user_hook_colocated_with_cycode( assert not any('cycode ai-guardrails' in c for c in commands) +def test_copilot_dedicated_file_install_uninstall_lifecycle(fs: FakeFilesystem) -> None: + """Copilot uses a dedicated Cycode-owned file: install creates it from + scratch, reinstall is idempotent, uninstall removes the file entirely.""" + copilot = Copilot() + hooks_path = copilot.settings_path('user') + + success, _ = install_hooks(copilot) + assert success is True + saved = json.loads(hooks_path.read_text()) + assert saved['version'] == 1 + assert set(saved['hooks']) == {'sessionStart', 'userPromptSubmitted', 'preToolUse'} + assert all(len(entries) == 1 for entries in saved['hooks'].values()) + + # Reinstall (also flipping mode) must replace, not duplicate. + success, _ = install_hooks(copilot, report_mode=True) + assert success is True + saved = json.loads(hooks_path.read_text()) + assert all(len(entries) == 1 for entries in saved['hooks'].values()) + assert saved['hooks']['preToolUse'][0]['bash'].endswith('&') + + # Uninstall deletes the emptied dedicated file rather than leaving a husk. + success, _ = uninstall_hooks(copilot) + assert success is True + assert not hooks_path.exists() + + def test_create_policy_file_repo_scope(fs: FakeFilesystem) -> None: """Create a policy file in repo scope.""" repo_path = Path('/my-repo') diff --git a/tests/cli/commands/ai_guardrails/test_session_start_command.py b/tests/cli/commands/ai_guardrails/test_session_start_command.py index 6beaa615..eaec8531 100644 --- a/tests/cli/commands/ai_guardrails/test_session_start_command.py +++ b/tests/cli/commands/ai_guardrails/test_session_start_command.py @@ -12,6 +12,7 @@ from cycode.cli.apps.ai_guardrails.ides import IDES, collect_all_session_contexts from cycode.cli.apps.ai_guardrails.ides import claude_code as _claude_mod from cycode.cli.apps.ai_guardrails.ides import codex as _codex_mod +from cycode.cli.apps.ai_guardrails.ides import copilot as _copilot_mod from cycode.cli.apps.ai_guardrails.ides import cursor as _cursor_mod from cycode.cli.apps.ai_guardrails.session_start_command import session_start_command @@ -285,19 +286,23 @@ def test_no_mcp_anywhere_still_reports_device( ) +@patch.object(_copilot_mod, '_collect_installed_plugins') +@patch.object(_copilot_mod, '_load_vscode_mcp_config') @patch.object(_codex_mod, '_load_codex_config') @patch.object(_cursor_mod, '_load_cursor_mcp_config') @patch.object(_claude_mod, 'load_claude_settings') @patch.object(_claude_mod, 'load_claude_config') @patch.object(_session_start_mod, 'get_ai_security_manager_client') @patch.object(_session_start_mod, 'get_authorization_info') -def test_claude_code_reports_global_file_and_plugin_metadata( +def test_claude_code_reports_config_files_and_plugin_metadata( mock_get_auth: MagicMock, mock_get_client: MagicMock, mock_load_config: MagicMock, mock_load_settings: MagicMock, mock_load_cursor: MagicMock, mock_load_codex: MagicMock, + mock_load_vscode: MagicMock, + mock_collect_copilot_plugins: MagicMock, mock_ctx: MagicMock, tmp_path: Path, ) -> None: @@ -308,6 +313,8 @@ def test_claude_code_reports_global_file_and_plugin_metadata( mock_get_client.return_value = mock_ai_client mock_load_cursor.return_value = None mock_load_codex.return_value = None + mock_load_vscode.return_value = None + mock_collect_copilot_plugins.return_value = {} # Set up a fake plugin directory on disk. plugin_dir = tmp_path / 'dummy-plugin' @@ -360,6 +367,8 @@ def test_claude_code_reports_global_file_and_plugin_metadata( ) +@patch.object(_copilot_mod, '_collect_installed_plugins') +@patch.object(_copilot_mod, '_load_vscode_mcp_config') @patch.object(_codex_mod, '_load_codex_config') @patch.object(_claude_mod, 'load_claude_settings') @patch.object(_claude_mod, 'load_claude_config') @@ -373,6 +382,8 @@ def test_cursor_trigger_sweeps_other_ides( mock_load_config: MagicMock, mock_load_settings: MagicMock, mock_load_codex: MagicMock, + mock_load_vscode: MagicMock, + mock_collect_copilot_plugins: MagicMock, mock_ctx: MagicMock, ) -> None: """A Cursor-triggered session start also reports Claude's config via config_files.""" @@ -385,6 +396,8 @@ def test_cursor_trigger_sweeps_other_ides( mock_load_config.return_value = {'mcpServers': claude_servers} mock_load_settings.return_value = None mock_load_codex.return_value = None + mock_load_vscode.return_value = None + mock_collect_copilot_plugins.return_value = {} payload = {'conversation_id': 'conv-456', 'model': 'gpt-4'} @@ -549,6 +562,7 @@ def test_collect_all_session_contexts_merges_plugins_first_wins() -> None: patch.object(IDES['cursor'], 'get_session_context', return_value=(None, {})), patch.object(IDES['claude-code'], 'get_session_context', return_value=(None, {'plug@m': claude_plugin})), patch.object(IDES['codex'], 'get_session_context', return_value=(None, {'plug@m': codex_plugin})), + patch.object(IDES['copilot'], 'get_session_context', return_value=(None, {})), ): _, plugins = collect_all_session_contexts() From 0eeb0f29ac9fe5354c10275b541317b1f7bb9660 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:44:42 +0300 Subject: [PATCH 331/347] CM-65504: Skip synthetic task-notification prompts in Claude Code guardrails scan (#500) Co-authored-by: Claude Fable 5 --- cycode/cli/apps/ai_guardrails/ides/base.py | 9 ++++++ .../apps/ai_guardrails/ides/claude_code.py | 10 ++++++ .../apps/ai_guardrails/scan/scan_command.py | 8 +++++ .../ai_guardrails/ides/test_claude_code.py | 31 +++++++++++++++++++ .../ai_guardrails/ides/test_contract.py | 5 +++ .../ai_guardrails/scan/test_scan_command.py | 27 ++++++++++++++++ 6 files changed, 90 insertions(+) diff --git a/cycode/cli/apps/ai_guardrails/ides/base.py b/cycode/cli/apps/ai_guardrails/ides/base.py index 84e4315f..29b4b200 100644 --- a/cycode/cli/apps/ai_guardrails/ides/base.py +++ b/cycode/cli/apps/ai_guardrails/ides/base.py @@ -157,6 +157,15 @@ def matches_payload(self, raw_payload: dict) -> bool: event (e.g. Cursor reading Claude Code hooks from ~/.claude/settings.json). """ + def is_synthetic_prompt(self, raw_payload: dict) -> bool: + """Return True when a prompt event carries IDE/harness-generated content + rather than text the user typed. + + Synthetic prompts are skipped without scanning or telemetry. + Default: False. Override for IDEs that inject synthetic user turns. + """ + return False + @abstractmethod def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: """Normalize a raw stdin payload into the canonical ``AIHookPayload``.""" diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py index f48794ef..1b3f618b 100644 --- a/cycode/cli/apps/ai_guardrails/ides/claude_code.py +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -22,6 +22,10 @@ _CLAUDE_CODE_EVENT_NAMES = frozenset({'UserPromptSubmit', 'PreToolUse'}) +# When a fork/subagent completes, the harness injects its result into the parent +# session as a synthetic user turn, which fires UserPromptSubmit. +_SYNTHETIC_PROMPT_PREFIXES = ('',) + _USER_HOOKS_DIR = Path.home() / '.claude' _HOOKS_FILE_NAME = 'settings.json' _REPO_SUBDIR = '.claude' @@ -284,6 +288,12 @@ def matches_payload(self, raw_payload: dict) -> bool: # processed as Claude Code events. return raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES and 'transcript_path' in raw_payload + def is_synthetic_prompt(self, raw_payload: dict) -> bool: + if raw_payload.get('hook_event_name') != 'UserPromptSubmit': + return False + prompt = raw_payload.get('prompt') or '' + return prompt.lstrip().startswith(_SYNTHETIC_PROMPT_PREFIXES) + def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: hook_event_name = raw_payload.get('hook_event_name', '') tool_name = raw_payload.get('tool_name', '') diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index cad92263..1a389d5e 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -116,6 +116,14 @@ def scan_command( output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) return + # Fork/subagent completions arrive as synthetic user turns (e.g. Claude Code's + # ); they are agent-generated, not user prompts - skip before + # parse_hook_payload, which reads the transcript and IDE config from disk. + if ide_integration.is_synthetic_prompt(payload): + logger.debug('Synthetic prompt detected, skipping scan') + output_json(ide_integration.build_hook_response(HookDecision.allow(AiHookEventType.PROMPT))) + return + unified_payload = ide_integration.parse_hook_payload(payload) event_name = unified_payload.event_name logger.debug( diff --git a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py index 60fb331e..4dcab376 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py +++ b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py @@ -50,6 +50,37 @@ def test_matches_payload_rejects_vscode_copilot_payloads() -> None: ) +def test_is_synthetic_prompt_task_notification() -> None: + claude = ClaudeCode() + payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'prompt': 'Task dummy-task-1 completed', + } + assert claude.is_synthetic_prompt(payload) is True + + payload['prompt'] = ' \nTask dummy-task-2 completed' + assert claude.is_synthetic_prompt(payload) is True + + +def test_is_synthetic_prompt_regular_prompt() -> None: + claude = ClaudeCode() + assert claude.is_synthetic_prompt({'hook_event_name': 'UserPromptSubmit', 'prompt': 'Test prompt'}) is False + assert claude.is_synthetic_prompt({'hook_event_name': 'UserPromptSubmit', 'prompt': ''}) is False + assert claude.is_synthetic_prompt({'hook_event_name': 'UserPromptSubmit'}) is False + + +def test_is_synthetic_prompt_ignores_tool_events() -> None: + claude = ClaudeCode() + payload = { + 'hook_event_name': 'PreToolUse', + 'tool_name': 'Read', + 'tool_input': {'file_path': '/path/to/file'}, + 'prompt': 'not a prompt event', + } + assert claude.is_synthetic_prompt(payload) is False + + def test_parse_prompt_payload() -> None: unified = ClaudeCode().parse_hook_payload( { diff --git a/tests/cli/commands/ai_guardrails/ides/test_contract.py b/tests/cli/commands/ai_guardrails/ides/test_contract.py index 7d7ab773..0984c97a 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_contract.py +++ b/tests/cli/commands/ai_guardrails/ides/test_contract.py @@ -82,6 +82,11 @@ def test_matches_payload_rejects_unrelated_event_names(ide: IDE) -> None: assert ide.matches_payload({'hook_event_name': 'completely-fabricated-event'}) is False +def test_is_synthetic_prompt_rejects_empty(ide: IDE) -> None: + """The safe default: no payload is ever treated as synthetic unless an IDE opts in.""" + assert ide.is_synthetic_prompt({}) is False + + @pytest.mark.parametrize('event_type', list(AiHookEventType)) def test_build_hook_response_allow_returns_dict(ide: IDE, event_type: AiHookEventType) -> None: """ALLOW for every canonical event type yields a serializable dict.""" 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 349a1ee3..8b7b611e 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_scan_command.py +++ b/tests/cli/commands/ai_guardrails/scan/test_scan_command.py @@ -81,6 +81,33 @@ def test_cursor_payload_with_claude_code_ide( assert response == {} # Claude Code allow_prompt returns empty dict +class TestSyntheticPromptSkipsProcessing: + """Tests that verify synthetic (harness-generated) prompts cause early exit without API calls.""" + + def test_task_notification_prompt_skipped( + self, + mock_ctx: MagicMock, + mocker: MockerFixture, + capsys: pytest.CaptureFixture[str], + mock_scan_command_deps: dict[str, MagicMock], + ) -> None: + """Fork/subagent completions arrive as synthetic user turns + that fire UserPromptSubmit in the parent session; they must not be scanned.""" + payload = { + 'hook_event_name': 'UserPromptSubmit', + 'session_id': 'session-123', + 'transcript_path': '/home/user/.claude/projects/transcript.jsonl', + 'prompt': 'Task dummy-task-1 completed', + } + 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.""" From dea03fbcbeca0406ceaae810e16882b864442ede Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:45:37 +0300 Subject: [PATCH 332/347] CM-69233 ai guardrails respect ignores (#499) Co-authored-by: Claude Fable 5 --- .../cli/apps/ai_guardrails/scan/handlers.py | 7 +- cycode/cli/files_collector/file_excluder.py | 4 +- .../ai_guardrails/scan/test_handlers.py | 49 ++- .../configure/test_configure_command.py | 390 ++++++------------ 4 files changed, 178 insertions(+), 272 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index 8b8a2d71..ab05482a 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -26,6 +26,7 @@ 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.files_collector.file_excluder import is_path_configured_in_exclusions 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 @@ -337,7 +338,7 @@ def _perform_scan( scan_id = local_scan_result.scan_id - if local_scan_result.detections_count > 0: + if local_scan_result.issue_detected: violation_summary = build_violation_summary([local_scan_result]) return violation_summary, scan_id @@ -360,6 +361,10 @@ def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) -> if not file_path or not os.path.isfile(file_path): return None, None + if is_path_configured_in_exclusions(str(ScanTypeOption.SECRET), os.path.abspath(file_path)): + logger.debug('Skipping scan; the path is in the ignore paths list, %s', {'file_path': file_path}) + return None, None + max_bytes = get_policy_value(policy, 'secrets', 'max_bytes', default=200000) with open(file_path, encoding='utf-8', errors='replace') as f: diff --git a/cycode/cli/files_collector/file_excluder.py b/cycode/cli/files_collector/file_excluder.py index fc61f0e2..066d7669 100644 --- a/cycode/cli/files_collector/file_excluder.py +++ b/cycode/cli/files_collector/file_excluder.py @@ -25,7 +25,7 @@ def _is_subpath_of_cycode_configuration_folder(filename: str) -> bool: ) -def _is_path_configured_in_exclusions(scan_type: str, file_path: str) -> bool: +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, [] ) @@ -106,7 +106,7 @@ def _is_relevant_file_to_scan_common(self, scan_type: str, filename: str) -> boo ) return False - if _is_path_configured_in_exclusions(scan_type, filename): + 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} ) diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index 36352a38..01f8790c 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -1,5 +1,6 @@ """Tests for AI guardrails handlers.""" +import os from typing import Any from unittest.mock import MagicMock, patch @@ -8,12 +9,15 @@ from cycode.cli.apps.ai_guardrails.ides.base import DecisionAction, HookDecision from cycode.cli.apps.ai_guardrails.scan.handlers import ( + _perform_scan, + _scan_path_for_secrets, 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 AiHookEventType, AIHookOutcome, BlockReason +from cycode.cli.models import Document, LocalScanResult @pytest.fixture @@ -357,8 +361,6 @@ def test_handle_before_read_file_sensitive_path_scan_disabled_warns( def test_scan_path_for_secrets_directory(mock_ctx: MagicMock, default_policy: dict[str, Any], fs: Any) -> None: """Test that _scan_path_for_secrets returns (None, None) for directories.""" - from cycode.cli.apps.ai_guardrails.scan.handlers import _scan_path_for_secrets - fs.create_dir('/path/to/some_directory') result = _scan_path_for_secrets(mock_ctx, '/path/to/some_directory', default_policy) @@ -366,6 +368,49 @@ def test_scan_path_for_secrets_directory(mock_ctx: MagicMock, default_policy: di assert result == (None, None) +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._perform_scan') +def test_scan_path_for_secrets_skips_path_configured_in_exclusions( + mock_perform_scan: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any], fs: Any +) -> None: + """Test that a path ignored via `cycode ignore --by-path` is not scanned.""" + # `cycode ignore --by-path` stores absolute paths; on Windows that includes the drive prefix + excluded_dir = os.path.abspath(os.path.join(os.sep, 'project', 'secrets')) + file_path = os.path.join(excluded_dir, 'creds.env') + fs.create_file(file_path, contents='password=hunter2') + mock_perform_scan.return_value = ('Cycode found 1 violations', 'scan-id-123') + + with patch( + 'cycode.cli.files_collector.file_excluder.configuration_manager.get_exclusions_by_scan_type', + return_value={'paths': [excluded_dir]}, + ): + result = _scan_path_for_secrets(mock_ctx, file_path, default_policy) + + assert result == (None, None) + mock_perform_scan.assert_not_called() + + +def test_perform_scan_no_violation_when_all_detections_excluded(mock_ctx: MagicMock) -> None: + """Test that detections filtered out by ignore rules do not produce a violation.""" + local_scan_result = LocalScanResult( + scan_id='scan-id-123', + report_url=None, + document_detections=[], + issue_detected=False, + detections_count=1, + relevant_detections_count=0, + ) + document = Document(path='prompt-content.txt', content='some content', is_git_diff_format=False) + + with patch( + 'cycode.cli.apps.ai_guardrails.scan.handlers._get_scan_documents_thread_func', + return_value=lambda batch: ('scan-id-123', None, local_scan_result), + ): + violation_summary, scan_id = _perform_scan(mock_ctx, [document], {}, timeout_seconds=5.0) + + assert violation_summary is None + assert scan_id == 'scan-id-123' + + # Tests for handle_before_mcp_execution diff --git a/tests/cli/commands/configure/test_configure_command.py b/tests/cli/commands/configure/test_configure_command.py index 0d763edd..3548d4ed 100644 --- a/tests/cli/commands/configure/test_configure_command.py +++ b/tests/cli/commands/configure/test_configure_command.py @@ -1,314 +1,170 @@ +import os from typing import TYPE_CHECKING -from typer.testing import CliRunner +import pytest +import yaml +from click.testing import CliRunner +from typer.main import get_command from cycode.cli.app import app +from cycode.cli.apps.configure.consts import CONFIGURATION_MANAGER, CREDENTIALS_MANAGER +from cycode.cli.user_settings.config_file_manager import ConfigFileManager +from cycode.cli.user_settings.credentials_manager import CredentialsManager if TYPE_CHECKING: from pytest_mock import MockerFixture +# Built eagerly on the real filesystem; building it under pyfakefs breaks typer's +# pathlib.Path parameter introspection. +_click_app = get_command(app) -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' - 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, - ) - 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( - 'typer.prompt', - 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' - ) - mocked_update_app_base_url = mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_app_base_url' - ) - - # Act - CliRunner().invoke(app, ['configure']) - - # 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) +# `cycode configure` reads/writes the real ~/.cycode files; run every test on pyfakefs +# so file access never reaches the developer's machine. +pytestmark = pytest.mark.usefixtures('fs') +_CURRENT_CREDENTIALS = { + CredentialsManager.CLIENT_ID_FIELD_NAME: 'current client id', + CredentialsManager.CLIENT_SECRET_FIELD_NAME: 'current client secret', + CredentialsManager.ID_TOKEN_FIELD_NAME: 'current id token', +} +_CURRENT_CONFIG = { + ConfigFileManager.ENVIRONMENT_SECTION_NAME: { + ConfigFileManager.API_URL_FIELD_NAME: 'current api url', + ConfigFileManager.APP_URL_FIELD_NAME: 'current app url', + } +} -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' - 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', - ) - 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( - 'typer.prompt', - 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_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' - ) - 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) +def _credentials_filename() -> str: + return CREDENTIALS_MANAGER.get_filename() -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('typer.prompt', side_effect=['', '', client_id_user_input, '', '']) - mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' - ) +def _config_filename() -> str: + return CONFIGURATION_MANAGER.global_config_file_manager.get_filename() - # Act - CliRunner().invoke(app, ['configure']) - # Assert - mocked_update_credentials.assert_called_once_with(client_id_user_input, current_client_id) +def _seed_yaml(filename: str, content: dict) -> None: + os.makedirs(os.path.dirname(filename), exist_ok=True) + with open(filename, 'w', encoding='UTF-8') as file: + yaml.safe_dump(content, file) -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' +def _read_yaml(filename: str) -> dict: + with open(filename, encoding='UTF-8') as file: + return yaml.safe_load(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('typer.prompt', side_effect=['', '', '', client_secret_user_input, '']) - mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' - ) +def _run_configure(mocker: 'MockerFixture', prompt_answers: list[str]) -> None: + # Prompt order: api url, app url, client id, client secret, id token + mocker.patch('typer.prompt', side_effect=prompt_answers) + result = CliRunner().invoke(_click_app, ['configure']) + assert result.exit_code == 0 - # Act - CliRunner().invoke(app, ['configure']) - # Assert - mocked_update_credentials.assert_called_once_with(current_client_id, client_secret_user_input) +def test_configure_command_no_exist_values_in_file(mocker: 'MockerFixture') -> None: + _run_configure(mocker, ['new api url', 'new app url', 'new client id', 'new client secret', 'new id token']) + assert _read_yaml(_credentials_filename()) == { + CredentialsManager.CLIENT_ID_FIELD_NAME: 'new client id', + CredentialsManager.CLIENT_SECRET_FIELD_NAME: 'new client secret', + CredentialsManager.ID_TOKEN_FIELD_NAME: 'new id token', + } + assert _read_yaml(_config_filename()) == { + ConfigFileManager.ENVIRONMENT_SECTION_NAME: { + ConfigFileManager.API_URL_FIELD_NAME: 'new api url', + ConfigFileManager.APP_URL_FIELD_NAME: 'new app url', + } + } -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, - ) +def test_configure_command_update_current_configs_in_files(mocker: 'MockerFixture') -> None: + _seed_yaml(_credentials_filename(), _CURRENT_CREDENTIALS) + _seed_yaml(_config_filename(), _CURRENT_CONFIG) - # 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, '', '', '', '']) - mocked_update_api_base_url = mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_api_base_url' - ) + _run_configure(mocker, ['new api url', 'new app url', 'new client id', 'new client secret', 'new id token']) - # Act - CliRunner().invoke(app, ['configure']) + assert _read_yaml(_credentials_filename()) == { + CredentialsManager.CLIENT_ID_FIELD_NAME: 'new client id', + CredentialsManager.CLIENT_SECRET_FIELD_NAME: 'new client secret', + CredentialsManager.ID_TOKEN_FIELD_NAME: 'new id token', + } + assert _read_yaml(_config_filename()) == { + ConfigFileManager.ENVIRONMENT_SECTION_NAME: { + ConfigFileManager.API_URL_FIELD_NAME: 'new api url', + ConfigFileManager.APP_URL_FIELD_NAME: 'new app url', + } + } - # Assert - mocked_update_api_base_url.assert_called_once_with(api_url_user_input) +def test_set_credentials_update_only_client_id(mocker: 'MockerFixture') -> None: + _seed_yaml(_credentials_filename(), _CURRENT_CREDENTIALS) -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' + _run_configure(mocker, ['', '', 'new client id', '', '']) - 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), - ) + # Client id is replaced in both the token and OIDC credential pairs; everything else is kept + assert _read_yaml(_credentials_filename()) == { + **_CURRENT_CREDENTIALS, + CredentialsManager.CLIENT_ID_FIELD_NAME: 'new client id', + } + assert not os.path.exists(_config_filename()) - 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' - ) +def test_configure_command_update_only_client_secret(mocker: 'MockerFixture') -> None: + _seed_yaml(_credentials_filename(), _CURRENT_CREDENTIALS) - # Act - CliRunner().invoke(app, ['configure']) + _run_configure(mocker, ['', '', '', 'new client secret', '']) - # Assert - mocked_update_oidc_credentials.assert_called_once_with(current_client_id, new_id_token) + assert _read_yaml(_credentials_filename()) == { + **_CURRENT_CREDENTIALS, + CredentialsManager.CLIENT_SECRET_FIELD_NAME: 'new client secret', + } -def test_configure_command_should_not_update_credentials(mocker: 'MockerFixture') -> None: - # Arrange - client_id_user_input = '' - client_secret_user_input = '' +def test_configure_command_update_only_api_url(mocker: 'MockerFixture') -> None: + _seed_yaml(_config_filename(), _CURRENT_CONFIG) - mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.get_credentials_from_file', - return_value=('client id file', 'client secret file'), - ) + _run_configure(mocker, ['new api url', '', '', '', '']) - # 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, '']) - mocked_update_credentials = mocker.patch( - 'cycode.cli.user_settings.credentials_manager.CredentialsManager.update_credentials' - ) + assert _read_yaml(_config_filename()) == { + ConfigFileManager.ENVIRONMENT_SECTION_NAME: { + ConfigFileManager.API_URL_FIELD_NAME: 'new api url', + ConfigFileManager.APP_URL_FIELD_NAME: 'current app url', + } + } + assert not os.path.exists(_credentials_filename()) - # Act - CliRunner().invoke(app, ['configure']) - # Assert - assert not mocked_update_credentials.called +def test_configure_command_update_only_id_token(mocker: 'MockerFixture') -> None: + _seed_yaml(_credentials_filename(), _CURRENT_CREDENTIALS) + + _run_configure(mocker, ['', '', '', '', 'new id token']) + + assert _read_yaml(_credentials_filename()) == { + **_CURRENT_CREDENTIALS, + CredentialsManager.ID_TOKEN_FIELD_NAME: 'new id token', + } + + +def test_configure_command_should_not_update_credentials(mocker: 'MockerFixture') -> None: + _seed_yaml(_credentials_filename(), _CURRENT_CREDENTIALS) + + _run_configure(mocker, ['', '', '', '', '']) + + assert _read_yaml(_credentials_filename()) == _CURRENT_CREDENTIALS 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('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' - ) - mocked_update_app_base_url = mocker.patch( - 'cycode.cli.user_settings.config_file_manager.ConfigFileManager.update_app_base_url' - ) - - # Act - CliRunner().invoke(app, ['configure']) - - # Assert - assert not mocked_update_api_base_url.called - assert not mocked_update_app_base_url.called + _seed_yaml(_config_filename(), _CURRENT_CONFIG) + + _run_configure(mocker, ['', '', '', '', '']) + + assert _read_yaml(_config_filename()) == _CURRENT_CONFIG 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() + _seed_yaml(_credentials_filename(), _CURRENT_CREDENTIALS) + + # Re-entering the same client id must not rewrite anything + _run_configure(mocker, ['', '', 'current client id', '', '']) + + assert _read_yaml(_credentials_filename()) == _CURRENT_CREDENTIALS From ed2f7148b5258e9c754fa160ebc77d46b1a06582 Mon Sep 17 00:00:00 2001 From: omer-roth Date: Thu, 23 Jul 2026 10:00:20 +0300 Subject: [PATCH 333/347] CM-68642: stop SCA scan on restore command failure with --stop-on-error (#495) Co-authored-by: Claude Opus 4.8 --- .../sca/base_restore_dependencies.py | 3 ++ .../sca/test_base_restore_dependencies.py | 53 ++++++++++++++++++- 2 files changed, 55 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 06431f72..d5167e92 100644 --- a/cycode/cli/files_collector/sca/base_restore_dependencies.py +++ b/cycode/cli/files_collector/sca/base_restore_dependencies.py @@ -40,6 +40,9 @@ def execute_commands( for command in commands: command_output = shell(command=command, timeout=timeout, working_directory=working_directory) + if command_output is None: # shell returns None when the command exited non-zero + logger.debug('Restore command failed, %s', {'command': command}) + return None if command_output: outputs.append(command_output) diff --git a/tests/cli/files_collector/sca/test_base_restore_dependencies.py b/tests/cli/files_collector/sca/test_base_restore_dependencies.py index b291a95f..7d8d8743 100644 --- a/tests/cli/files_collector/sca/test_base_restore_dependencies.py +++ b/tests/cli/files_collector/sca/test_base_restore_dependencies.py @@ -11,7 +11,7 @@ import pytest import typer -from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies +from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, execute_commands from cycode.cli.models import Document _LOCK_FILE_NAME = 'generated.lock' @@ -68,6 +68,39 @@ def side_effect( return side_effect +class TestExecuteCommands: + """Directly test the shell-failure sentinel handling in execute_commands.""" + + def test_returns_none_when_a_command_fails(self) -> None: + """shell() returns None on non-zero exit; execute_commands must propagate None, not ''.""" + with patch(f'{_BASE_MODULE}.shell', return_value=None): + result = execute_commands([['poetry', 'lock']], timeout=30) + + assert result is None + + def test_stops_at_first_failing_command(self) -> None: + """A failure in an earlier command short-circuits; later commands do not run.""" + mock_shell = MagicMock(side_effect=[None, 'should-not-run']) + with patch(f'{_BASE_MODULE}.shell', mock_shell): + result = execute_commands([['a'], ['b']], timeout=30) + + assert result is None + assert mock_shell.call_count == 1 + + def test_empty_output_success_is_not_treated_as_failure(self) -> None: + """A successful command with empty stdout ('') must NOT be treated as a failure.""" + with patch(f'{_BASE_MODULE}.shell', return_value=''): + result = execute_commands([['poetry', 'lock']], timeout=30) + + assert result == '' + + def test_joins_successful_outputs(self) -> None: + with patch(f'{_BASE_MODULE}.shell', side_effect=['out1', 'out2']): + result = execute_commands([['a'], ['b']], timeout=30) + + assert result == 'out1\nout2' + + class TestCleanupGeneratedFile: def test_generated_lockfile_is_deleted_after_restore(self, handler: _MinimalRestoreHandler, tmp_path: Path) -> None: doc = _make_doc(tmp_path) @@ -132,6 +165,24 @@ def test_failed_command_returns_none_and_no_file_created( assert result is None assert not lock_path.exists() + def test_shell_failure_propagates_to_none_and_no_lockfile( + self, handler: _MinimalRestoreHandler, tmp_path: Path + ) -> None: + """End-to-end failure path: shell() returns None (non-zero exit) -> restore returns None. + + Regression for the stop-on-error bug: execute_commands must NOT swallow a failed + command into an empty-string success. This exercises the real execute_commands with + only shell() mocked (the layer where a non-zero exit is signalled as None). + """ + doc = _make_doc(tmp_path) + lock_path = tmp_path / _LOCK_FILE_NAME + + with patch(f'{_BASE_MODULE}.shell', return_value=None): + result = handler.try_restore_dependencies(doc) + + assert result is None, 'A failed restore command must return None so stop-on-error can fire' + assert not lock_path.exists() + def test_generated_file_content_available_in_document_after_deletion( self, handler: _MinimalRestoreHandler, tmp_path: Path ) -> None: From 349fe1d3bdedf9bbebb1653cb87e5b323be98d4b Mon Sep 17 00:00:00 2001 From: valeriistryhun-dev Date: Mon, 27 Jul 2026 09:36:39 +0200 Subject: [PATCH 334/347] CM-68709: Add pip package manager support to SCA local scans (#502) --- README.md | 1 + cycode/cli/consts.py | 4 +- .../sca/python/restore_pip_dependencies.py | 69 ++++++ .../files_collector/sca/sca_file_collector.py | 2 + .../python/test_restore_pip_dependencies.py | 217 ++++++++++++++++++ 5 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 cycode/cli/files_collector/sca/python/restore_pip_dependencies.py create mode 100644 tests/cli/files_collector/sca/python/test_restore_pip_dependencies.py diff --git a/README.md b/README.md index d48c4fcc..6ccbee88 100644 --- a/README.md +++ b/README.md @@ -814,6 +814,7 @@ The following ecosystems support automatic lockfile restoration: | NuGet | `*.csproj` | `packages.lock.json` | `dotnet restore --use-lock-file` | | Ruby | `Gemfile` | `Gemfile.lock` | `bundle --quiet` | | Poetry | `pyproject.toml` | `poetry.lock` | `poetry lock` | +| pip | `pyproject.toml` / `requirements.txt` | `pylock.toml` | `pip lock .` / `pip lock -r requirements.txt -o pylock.toml` | | Pipenv | `Pipfile` | `Pipfile.lock` | `pipenv lock` | | PHP Composer | `composer.json` | `composer.lock` | `composer update --no-cache --no-install --no-scripts --ignore-platform-reqs` | diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 37ef2298..9007fda9 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -118,6 +118,7 @@ 'pyproject.toml', 'uv.lock', 'poetry.lock', + 'pylock.toml', 'pipfile', 'pipfile.lock', 'requirements.txt', @@ -175,8 +176,9 @@ 'sbt': ['build.sbt', 'build.scala', 'build.sbt.lock'], 'pypi_uv': ['pyproject.toml', 'uv.lock'], 'pypi_poetry': ['pyproject.toml', 'poetry.lock'], + 'pypi_pip': ['pyproject.toml', 'pylock.toml'], 'pypi_pipenv': ['Pipfile', 'Pipfile.lock'], - 'pypi_requirements': ['requirements.txt'], + 'pypi_requirements': ['requirements.txt', 'pylock.toml'], 'pypi_setup': ['setup.py'], 'hex': ['mix.exs', 'mix.lock'], 'swift_pm': ['Package.swift', 'Package.resolved'], diff --git a/cycode/cli/files_collector/sca/python/restore_pip_dependencies.py b/cycode/cli/files_collector/sca/python/restore_pip_dependencies.py new file mode 100644 index 00000000..29ebfc6e --- /dev/null +++ b/cycode/cli/files_collector/sca/python/restore_pip_dependencies.py @@ -0,0 +1,69 @@ +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('Pip Restore Dependencies') + +PIP_PYPROJECT_MANIFEST_FILE_NAME = 'pyproject.toml' +PIP_REQUIREMENTS_MANIFEST_FILE_NAME = 'requirements.txt' +PIP_LOCK_FILE_NAME = 'pylock.toml' + +_POETRY_TOOL_SECTION = '[tool.poetry]' +_UV_TOOL_SECTION = '[tool.uv]' + + +def _indicates_plain_pip(pyproject_content: Optional[str]) -> bool: + """Return True if pyproject.toml content signals a plain-pip project (no Poetry, no uv).""" + if not pyproject_content: + return False + return _POETRY_TOOL_SECTION not in pyproject_content and _UV_TOOL_SECTION not in pyproject_content + + +class RestorePipDependencies(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: + manifest_name = Path(document.path).name + + if manifest_name == PIP_REQUIREMENTS_MANIFEST_FILE_NAME: + return True + + if manifest_name != PIP_PYPROJECT_MANIFEST_FILE_NAME: + return False + + manifest_dir = self.get_manifest_dir(document) + if manifest_dir and (Path(manifest_dir) / PIP_LOCK_FILE_NAME).is_file(): + return True + + return _indicates_plain_pip(document.content) + + def try_restore_dependencies(self, document: Document) -> Optional[Document]: + manifest_dir = self.get_manifest_dir(document) + lockfile_path = Path(manifest_dir) / PIP_LOCK_FILE_NAME if manifest_dir else None + + if lockfile_path and lockfile_path.is_file(): + content = get_file_content(str(lockfile_path)) + relative_path = build_dep_tree_path(document.path, PIP_LOCK_FILE_NAME) + logger.debug('Using existing pylock.toml, %s', {'path': str(lockfile_path)}) + return Document(relative_path, content, self.is_git_diff) + + return super().try_restore_dependencies(document) + + def get_commands(self, manifest_file_path: str) -> list[list[str]]: + if Path(manifest_file_path).name == PIP_REQUIREMENTS_MANIFEST_FILE_NAME: + return [['pip', 'lock', '-r', 'requirements.txt', '-o', PIP_LOCK_FILE_NAME]] + + return [['pip', 'lock', '.']] + + def get_lock_file_name(self) -> str: + return PIP_LOCK_FILE_NAME + + def get_lock_file_names(self) -> list[str]: + return [PIP_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 6bcfd494..4db5cd04 100644 --- a/cycode/cli/files_collector/sca/sca_file_collector.py +++ b/cycode/cli/files_collector/sca/sca_file_collector.py @@ -17,6 +17,7 @@ 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_pip_dependencies import RestorePipDependencies 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.python.restore_uv_dependencies import RestoreUvDependencies @@ -164,6 +165,7 @@ def _get_restore_handlers(ctx: typer.Context, is_git_diff: bool) -> list[BaseRes RestoreRubyDependencies(ctx, is_git_diff, build_dep_tree_timeout), RestoreUvDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be before Poetry for pyproject.toml RestorePoetryDependencies(ctx, is_git_diff, build_dep_tree_timeout), + RestorePipDependencies(ctx, is_git_diff, build_dep_tree_timeout), # Must be after Uv & Poetry (pyproject.toml) 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/python/test_restore_pip_dependencies.py b/tests/cli/files_collector/sca/python/test_restore_pip_dependencies.py new file mode 100644 index 00000000..c0f17476 --- /dev/null +++ b/tests/cli/files_collector/sca/python/test_restore_pip_dependencies.py @@ -0,0 +1,217 @@ +from pathlib import Path +from typing import Optional +from unittest.mock import MagicMock, patch + +import pytest +import typer + +from cycode.cli.files_collector.sca.python.restore_pip_dependencies import ( + PIP_LOCK_FILE_NAME, + RestorePipDependencies, +) +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_pip(mock_ctx: typer.Context) -> RestorePipDependencies: + return RestorePipDependencies(mock_ctx, is_git_diff=False, command_timeout=30) + + +class TestIsProject: + def test_plain_pyproject_toml_matches(self, restore_pip: RestorePipDependencies) -> None: + content = '[project]\nname = "my-project"\ndependencies = ["requests"]\n' + doc = Document('pyproject.toml', content) + assert restore_pip.is_project(doc) is True + + def test_pyproject_toml_with_poetry_section_does_not_match(self, restore_pip: RestorePipDependencies) -> None: + content = '[tool.poetry]\nname = "my-project"\n' + doc = Document('pyproject.toml', content) + assert restore_pip.is_project(doc) is False + + def test_pyproject_toml_with_uv_section_does_not_match(self, restore_pip: RestorePipDependencies) -> None: + content = '[tool.uv]\nindex-url = "https://example.com"\n' + doc = Document('pyproject.toml', content) + assert restore_pip.is_project(doc) is False + + def test_pyproject_toml_with_existing_pylock_matches( + self, restore_pip: RestorePipDependencies, tmp_path: Path + ) -> None: + (tmp_path / 'pyproject.toml').write_text('[project]\nname = "test"\n') + (tmp_path / PIP_LOCK_FILE_NAME).write_text('lock-version = "1.0"\n') + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[project]\nname = "test"\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + assert restore_pip.is_project(doc) is True + + def test_requirements_txt_matches(self, restore_pip: RestorePipDependencies) -> None: + doc = Document('requirements.txt', 'requests==2.31.0\n') + assert restore_pip.is_project(doc) is True + + def test_setup_py_does_not_match(self, restore_pip: RestorePipDependencies) -> None: + doc = Document('setup.py', 'from setuptools import setup\nsetup()\n') + assert restore_pip.is_project(doc) is False + + def test_empty_pyproject_toml_does_not_match(self, restore_pip: RestorePipDependencies) -> None: + # Same conservative behavior as Poetry/Uv's own is_project: empty content can't be + # confirmed as plain-pip, so don't claim it. + doc = Document('pyproject.toml', '') + assert restore_pip.is_project(doc) is False + + +class TestGetCommands: + def test_get_commands_for_pyproject_toml(self, restore_pip: RestorePipDependencies) -> None: + commands = restore_pip.get_commands('/path/to/pyproject.toml') + assert commands == [['pip', 'lock', '.']] + + def test_get_commands_for_requirements_txt(self, restore_pip: RestorePipDependencies) -> None: + commands = restore_pip.get_commands('/path/to/requirements.txt') + assert commands == [['pip', 'lock', '-r', 'requirements.txt', '-o', PIP_LOCK_FILE_NAME]] + + def test_get_lock_file_name(self, restore_pip: RestorePipDependencies) -> None: + assert restore_pip.get_lock_file_name() == PIP_LOCK_FILE_NAME + + +class TestTryRestoreDependencies: + def test_existing_pylock_returned_directly_for_pyproject_toml( + self, restore_pip: RestorePipDependencies, tmp_path: Path + ) -> None: + lock_content = 'lock-version = "1.0"\n\n[[packages]]\nname = "requests"\n' + (tmp_path / 'pyproject.toml').write_text('[project]\nname = "test"\n') + (tmp_path / PIP_LOCK_FILE_NAME).write_text(lock_content) + + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[project]\nname = "test"\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + result = restore_pip.try_restore_dependencies(doc) + + assert result is not None + assert PIP_LOCK_FILE_NAME in result.path + assert result.content == lock_content + + def test_existing_pylock_returned_directly_for_requirements_txt( + self, restore_pip: RestorePipDependencies, tmp_path: Path + ) -> None: + lock_content = 'lock-version = "1.0"\n\n[[packages]]\nname = "requests"\n' + (tmp_path / 'requirements.txt').write_text('requests==2.31.0\n') + (tmp_path / PIP_LOCK_FILE_NAME).write_text(lock_content) + + doc = Document( + str(tmp_path / 'requirements.txt'), + 'requests==2.31.0\n', + absolute_path=str(tmp_path / 'requirements.txt'), + ) + result = restore_pip.try_restore_dependencies(doc) + + assert result is not None + assert result.content == lock_content + + +_BASE_MODULE = 'cycode.cli.files_collector.sca.base_restore_dependencies' + + +class TestRestoreWithoutExistingLock: + def test_pyproject_toml_runs_pip_lock_dot(self, restore_pip: RestorePipDependencies, tmp_path: Path) -> None: + manifest_content = '[project]\nname = "test"\ndependencies = ["requests"]\n' + (tmp_path / 'pyproject.toml').write_text(manifest_content) + doc = Document( + str(tmp_path / 'pyproject.toml'), manifest_content, absolute_path=str(tmp_path / 'pyproject.toml') + ) + + seen_commands = [] + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + seen_commands.extend(commands) + (tmp_path / PIP_LOCK_FILE_NAME).write_text('lock-version = "1.0"\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_pip.try_restore_dependencies(doc) + + assert result is not None + assert seen_commands == [['pip', 'lock', '.']] + + def test_requirements_txt_runs_pip_lock_dash_r(self, restore_pip: RestorePipDependencies, tmp_path: Path) -> None: + (tmp_path / 'requirements.txt').write_text('requests==2.31.0\n') + doc = Document( + str(tmp_path / 'requirements.txt'), + 'requests==2.31.0\n', + absolute_path=str(tmp_path / 'requirements.txt'), + ) + + seen_commands = [] + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + seen_commands.extend(commands) + (tmp_path / PIP_LOCK_FILE_NAME).write_text('lock-version = "1.0"\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_pip.try_restore_dependencies(doc) + + assert result is not None + assert seen_commands == [['pip', 'lock', '-r', 'requirements.txt', '-o', PIP_LOCK_FILE_NAME]] + + +class TestCleanup: + def test_generated_lockfile_is_deleted_after_restore( + self, restore_pip: RestorePipDependencies, tmp_path: Path + ) -> None: + manifest_content = '[project]\nname = "test"\ndependencies = ["requests"]\n' + (tmp_path / 'pyproject.toml').write_text(manifest_content) + doc = Document( + str(tmp_path / 'pyproject.toml'), manifest_content, absolute_path=str(tmp_path / 'pyproject.toml') + ) + lock_path = tmp_path / PIP_LOCK_FILE_NAME + + def side_effect( + commands: list, + timeout: int, + output_file_path: Optional[str] = None, + working_directory: Optional[str] = None, + ) -> str: + lock_path.write_text('lock-version = "1.0"\n') + return 'output' + + with patch(f'{_BASE_MODULE}.execute_commands', side_effect=side_effect): + result = restore_pip.try_restore_dependencies(doc) + + assert result is not None + assert not lock_path.exists(), f'{PIP_LOCK_FILE_NAME} must be deleted after restore' + + def test_preexisting_lockfile_is_not_deleted(self, restore_pip: RestorePipDependencies, tmp_path: Path) -> None: + lock_content = 'lock-version = "1.0"\n' + (tmp_path / 'pyproject.toml').write_text('[project]\nname = "test"\n') + lock_path = tmp_path / PIP_LOCK_FILE_NAME + lock_path.write_text(lock_content) + doc = Document( + str(tmp_path / 'pyproject.toml'), + '[project]\nname = "test"\n', + absolute_path=str(tmp_path / 'pyproject.toml'), + ) + + result = restore_pip.try_restore_dependencies(doc) + + assert result is not None + assert lock_path.exists(), f'Pre-existing {PIP_LOCK_FILE_NAME} must not be deleted' From 9fdb9a4c764662eaf5f9de690111a7ce3c8f972d Mon Sep 17 00:00:00 2001 From: Christophe Date: Tue, 28 Jul 2026 13:34:51 +0200 Subject: [PATCH 335/347] Add MCP Toplist rank badge (#506) --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 6ccbee88..4965fb4f 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Cycode CLI User Guide +[![MCP Toplist](https://mcptoplist.com/badge/glama%2Fcycodehq%2Fcycode-cli.svg)](https://mcptoplist.com/server/glama%2Fcycodehq%2Fcycode-cli) + 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 walks you through both installation and usage. From ce8fa233c98d413b527b9eb51f49a2ee24976304 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:41:03 +0300 Subject: [PATCH 336/347] CM-68943 Send ai-guardrails hook context with the scan so report-mode findings become violations (#504) Co-authored-by: Claude Opus 5 (1M context) --- README.md | 30 ++++- cycode/cli/apps/ai_guardrails/consts.py | 9 +- cycode/cli/apps/ai_guardrails/ides/copilot.py | 4 +- .../cli/apps/ai_guardrails/install_command.py | 14 +-- .../cli/apps/ai_guardrails/scan/handlers.py | 113 ++++++++++++++---- .../apps/ai_guardrails/scan/scan_command.py | 4 + cycode/cli/apps/ai_guardrails/scan/types.py | 9 ++ cycode/cli/utils/host_info.py | 52 ++++++++ .../ai_guardrails/scan/test_handlers.py | 81 ++++++++++++- 9 files changed, 274 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index 4965fb4f..7ea39e36 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,9 @@ This guide walks you through both installation and usage. 1. [Discovering Commands](#discovering-commands) 2. [Examples](#platform-examples) 3. [Notes & Limitations](#platform-notes--limitations) -6. [Scan Command](#scan-command) +6. [AI Guardrails](#ai-guardrails-beta) + 1. [Data Collected by AI Guardrails](#data-collected-by-ai-guardrails) +7. [Scan Command](#scan-command) 1. [Running a Scan](#running-a-scan) 1. [Options](#options) 1. [Severity Threshold](#severity-option) @@ -704,6 +706,32 @@ cycode platform projects list --page-size 100 | jq '.items[].name' - **Override the cache TTL** with `CYCODE_SPEC_CACHE_TTL=`. +# AI Guardrails \[BETA\] + +AI Guardrails installs hooks into supported AI coding agents (Claude Code, Cursor, Copilot, Codex) so that +prompts, files the agent reads, and MCP tool arguments are scanned for secrets before they reach the model. + +## Data Collected by AI Guardrails + +Scanning happens server-side, so the scanned content leaves the machine: the prompt text, the contents of +files the agent reads, and MCP tool arguments are sent to your Cycode tenant to be checked for secrets. + +Each event is also reported with context about the developer and the machine, so a finding can be attributed +to the device and user it came from. Some of this is personal data: + +- **Device identifiers** — the machine's hostname and hardware serial number. +- **User identifiers** — the email address of the user signed in to the AI coding agent, and the local + operating-system username. +- **Environment details** — operating system and version, the AI agent, its version and the model in use, + the contents of the agent's MCP configuration files, and its enabled plugins. + +The hardware serial number is cached in a local temporary file, readable only by the user who ran the +command, so repeated hook invocations don't re-query the hardware. + +If collecting this data is not acceptable in your environment, do not install the guardrails hooks +(`cycode ai-guardrails uninstall` removes hooks that are already installed). + + # Scan Command ## Running a Scan diff --git a/cycode/cli/apps/ai_guardrails/consts.py b/cycode/cli/apps/ai_guardrails/consts.py index 8018fa73..4c962767 100644 --- a/cycode/cli/apps/ai_guardrails/consts.py +++ b/cycode/cli/apps/ai_guardrails/consts.py @@ -10,8 +10,13 @@ class PolicyMode(str, Enum): WARN = 'warn' -class InstallMode(str, Enum): - """Installation mode for ai-guardrails install command.""" +class GuardrailsMode(str, Enum): + """Guardrails enforcement mode. + + Used both as the ai-guardrails install-command mode and as the per-event + effective mode reported to the server (the ai_guardrails scan parameter's + `mode` field) + """ REPORT = 'report' BLOCK = 'block' diff --git a/cycode/cli/apps/ai_guardrails/ides/copilot.py b/cycode/cli/apps/ai_guardrails/ides/copilot.py index 12ef8f89..2cd6a427 100644 --- a/cycode/cli/apps/ai_guardrails/ides/copilot.py +++ b/cycode/cli/apps/ai_guardrails/ides/copilot.py @@ -333,9 +333,7 @@ def entry(command: str) -> dict: return { 'version': 1, 'hooks': { - 'sessionStart': [ - {'type': 'command', 'command': _SESSION_START_COMMAND, 'timeoutSec': _HOOK_TIMEOUT_SEC} - ], + 'sessionStart': [{'type': 'command', 'command': _SESSION_START_COMMAND}], 'userPromptSubmitted': [entry(_SCAN_PROMPT_COMMAND)], 'preToolUse': [entry(_SCAN_TOOL_COMMAND)], }, diff --git a/cycode/cli/apps/ai_guardrails/install_command.py b/cycode/cli/apps/ai_guardrails/install_command.py index 0ee5aacb..155cf83a 100644 --- a/cycode/cli/apps/ai_guardrails/install_command.py +++ b/cycode/cli/apps/ai_guardrails/install_command.py @@ -6,7 +6,7 @@ import typer from cycode.cli.apps.ai_guardrails.command_utils import console, resolve_repo_path, validate_scope -from cycode.cli.apps.ai_guardrails.consts import InstallMode, PolicyMode +from cycode.cli.apps.ai_guardrails.consts import GuardrailsMode, PolicyMode from cycode.cli.apps.ai_guardrails.hooks_manager import create_policy_file, install_hooks from cycode.cli.apps.ai_guardrails.ides import DEFAULT_IDE_NAME, IDES, resolve_ides @@ -40,14 +40,14 @@ def install_command( ), ] = None, mode: Annotated[ - InstallMode, + GuardrailsMode, typer.Option( '--mode', '-m', help='Installation mode: "report" for async non-blocking hooks with warn policy, ' '"block" for sync blocking hooks.', ), - ] = InstallMode.REPORT, + ] = GuardrailsMode.REPORT, ) -> None: """Install AI guardrails hooks for supported IDEs. @@ -65,7 +65,7 @@ def install_command( repo_path = resolve_repo_path(scope, repo_path) ides_to_install = resolve_ides(ide) - report_mode = mode == InstallMode.REPORT + report_mode = mode == GuardrailsMode.REPORT results: list[tuple[str, bool, str]] = [] for current_ide in ides_to_install: @@ -83,7 +83,7 @@ def install_command( all_success = False if any_success: - policy_mode = PolicyMode.WARN if mode == InstallMode.REPORT else PolicyMode.BLOCK + policy_mode = PolicyMode.WARN if mode == GuardrailsMode.REPORT else PolicyMode.BLOCK _install_policy(scope, repo_path, policy_mode) _print_next_steps(results, mode) @@ -99,7 +99,7 @@ def _install_policy(scope: str, repo_path: Optional[Path], policy_mode: PolicyMo console.print(f'[red]✗[/] {policy_message}', style='bold red') -def _print_next_steps(results: list[tuple[str, bool, str]], mode: InstallMode) -> None: +def _print_next_steps(results: list[tuple[str, bool, str]], mode: GuardrailsMode) -> None: console.print() console.print('[bold]Next steps:[/]') successful_ides = [name for name, success, _ in results if success] @@ -107,7 +107,7 @@ def _print_next_steps(results: list[tuple[str, bool, str]], mode: InstallMode) - console.print(f'1. Restart {ide_list} to activate the hooks') console.print('2. (Optional) Customize policy in ~/.cycode/ai-guardrails.yaml') console.print() - if mode == InstallMode.REPORT: + if mode == GuardrailsMode.REPORT: console.print('[dim]Report mode: hooks run async (non-blocking) and policy is set to warn.[/]') else: console.print('[dim]The hooks will scan prompts, file reads, and MCP tool calls for secrets.[/]') diff --git a/cycode/cli/apps/ai_guardrails/scan/handlers.py b/cycode/cli/apps/ai_guardrails/scan/handlers.py index ab05482a..e82c61d7 100644 --- a/cycode/cli/apps/ai_guardrails/scan/handlers.py +++ b/cycode/cli/apps/ai_guardrails/scan/handlers.py @@ -17,24 +17,29 @@ import typer -from cycode.cli.apps.ai_guardrails.consts import PolicyMode +from cycode.cli.apps.ai_guardrails.consts import GuardrailsMode, PolicyMode from cycode.cli.apps.ai_guardrails.ides.base import HookDecision 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.types import AiHookEventType, AIHookOutcome, BlockReason +from cycode.cli.apps.ai_guardrails.scan.types import ( + SECRETS_BLOCK_REASON_BY_EVENT_TYPE, + 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.files_collector.file_excluder import is_path_configured_in_exclusions from cycode.cli.models import Document +from cycode.cli.utils.host_info import get_hostname, get_serial_number 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') - HandlerFn = Callable[[typer.Context, AIHookPayload, dict], HookDecision] @@ -47,7 +52,7 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli ai_client.create_event(payload, AiHookEventType.PROMPT, AIHookOutcome.ALLOWED) return HookDecision.allow(AiHookEventType.PROMPT) - mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) + effective_mode = get_effective_mode(policy, prompt_config) 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) @@ -59,12 +64,18 @@ def handle_before_submit_prompt(ctx: typer.Context, payload: AIHookPayload, poli error_message = None try: - violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms) + violation_summary, scan_id = _scan_text_for_secrets( + ctx, + clipped, + timeout_ms, + payload=payload, + event_type=AiHookEventType.PROMPT, + effective_mode=effective_mode, + ) if violation_summary: - block_reason = BlockReason.SECRETS_IN_PROMPT - action = get_policy_value(prompt_config, 'action', default=PolicyMode.BLOCK) - if action == PolicyMode.BLOCK and mode == PolicyMode.BLOCK: + block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[AiHookEventType.PROMPT] + if effective_mode == GuardrailsMode.BLOCK: outcome = AIHookOutcome.BLOCKED user_message = f'{violation_summary}. Remove secrets before sending.' return HookDecision.deny(AiHookEventType.PROMPT, user_message) @@ -97,9 +108,8 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: ai_client.create_event(payload, AiHookEventType.FILE_READ, AIHookOutcome.ALLOWED) return HookDecision.allow(AiHookEventType.FILE_READ) - mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) file_path = payload.file_path or '' - action = get_policy_value(file_read_config, 'action', default=PolicyMode.BLOCK) + effective_mode = get_effective_mode(policy, file_read_config) scan_id = None block_reason = None @@ -110,7 +120,7 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: is_sensitive_path = is_denied_path(file_path, policy) if is_sensitive_path: block_reason = BlockReason.SENSITIVE_PATH - if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK: + if effective_mode == GuardrailsMode.BLOCK: outcome = AIHookOutcome.BLOCKED user_message = f'Cycode blocked sending {file_path} to the AI (sensitive path policy).' return HookDecision.deny( @@ -133,10 +143,12 @@ def handle_before_read_file(ctx: typer.Context, payload: AIHookPayload, policy: outcome = AIHookOutcome.ALLOWED if get_policy_value(file_read_config, 'scan_content', default=True): - violation_summary, scan_id = _scan_path_for_secrets(ctx, file_path, policy) + violation_summary, scan_id = _scan_path_for_secrets( + ctx, file_path, policy, payload=payload, effective_mode=effective_mode + ) if violation_summary: - block_reason = BlockReason.SECRETS_IN_FILE - if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK: + block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[AiHookEventType.FILE_READ] + if effective_mode == GuardrailsMode.BLOCK: outcome = AIHookOutcome.BLOCKED user_message = f'Cycode blocked reading {file_path}. {violation_summary}' return HookDecision.deny( @@ -191,7 +203,6 @@ class _ArgScanFeature: policy_key: str # 'mcp' or 'command_exec' scan_key: str # 'scan_arguments' or 'scan_command' event_type: AiHookEventType - block_reason: BlockReason deny_message: Callable[[str], str] deny_agent_message: str ask_message: Callable[[str], str] @@ -213,11 +224,10 @@ def _handle_arg_scan( ai_client.create_event(payload, feature.event_type, AIHookOutcome.ALLOWED) return HookDecision.allow(feature.event_type) - mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) 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(scan_text, max_bytes) - action = get_policy_value(feature_config, 'action', default=PolicyMode.BLOCK) + effective_mode = get_effective_mode(policy, feature_config) scan_id = None block_reason = None @@ -226,10 +236,17 @@ def _handle_arg_scan( try: if get_policy_value(feature_config, feature.scan_key, default=True): - violation_summary, scan_id = _scan_text_for_secrets(ctx, clipped, timeout_ms) + violation_summary, scan_id = _scan_text_for_secrets( + ctx, + clipped, + timeout_ms, + payload=payload, + event_type=feature.event_type, + effective_mode=effective_mode, + ) if violation_summary: - block_reason = feature.block_reason - if mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK: + block_reason = SECRETS_BLOCK_REASON_BY_EVENT_TYPE[feature.event_type] + if effective_mode == GuardrailsMode.BLOCK: outcome = AIHookOutcome.BLOCKED return HookDecision.deny( feature.event_type, @@ -275,7 +292,6 @@ def handle_before_mcp_execution(ctx: typer.Context, payload: AIHookPayload, poli policy_key='mcp', scan_key='scan_arguments', event_type=AiHookEventType.MCP_EXECUTION, - block_reason=BlockReason.SECRETS_IN_MCP_ARGS, deny_message=lambda v: f'Cycode blocked MCP tool call "{tool}". {v}', deny_agent_message='Do not pass secrets to tools. Use secret references (name/id) instead.', ask_message=lambda v: f'{v} in MCP tool call "{tool}". Allow execution?', @@ -295,6 +311,36 @@ def get_handler_for_event(event_type: str) -> Optional[HandlerFn]: return handlers.get(event_type) +def get_effective_mode(policy: dict, feature_config: dict) -> GuardrailsMode: + """The event only blocks when both the global mode and the per-guardrail action are block.""" + mode = get_policy_value(policy, 'mode', default=PolicyMode.BLOCK) + action = get_policy_value(feature_config, 'action', default=PolicyMode.BLOCK) + return GuardrailsMode.BLOCK if (mode == PolicyMode.BLOCK and action == PolicyMode.BLOCK) else GuardrailsMode.REPORT + + +def build_ai_guardrails_scan_parameters( + ctx: typer.Context, + paths: Optional[tuple[str, ...]], + payload: AIHookPayload, + event_type: AiHookEventType, + effective_mode: GuardrailsMode, +) -> dict: + scan_parameters = get_scan_parameters(ctx, paths) + scan_parameters.setdefault('metadata', {})['ai_guardrails'] = { + 'mode': effective_mode.value, + 'ide_provider': payload.ide_provider, + 'detection_source': SECRETS_BLOCK_REASON_BY_EVENT_TYPE[event_type].value, + 'device_id': get_serial_number(), + 'device_hostname': get_hostname(), + 'conversation_id': payload.conversation_id, + 'generation_id': payload.generation_id, + 'ide_user_email': payload.ide_user_email, + 'mcp_server_name': payload.mcp_server_name, + 'mcp_tool_name': payload.mcp_tool_name, + } + return scan_parameters + + def _setup_scan_context(ctx: typer.Context) -> typer.Context: """Set up minimal context for scan_documents without progress bars or printing.""" ctx.obj['progress_bar'] = DummyProgressBar([ScanProgressBarSection]) @@ -345,7 +391,14 @@ def _perform_scan( return None, scan_id -def _scan_text_for_secrets(ctx: typer.Context, text: str, timeout_ms: int) -> tuple[Optional[str], Optional[str]]: +def _scan_text_for_secrets( + ctx: typer.Context, + text: str, + timeout_ms: int, + payload: AIHookPayload, + event_type: AiHookEventType, + effective_mode: GuardrailsMode, +) -> tuple[Optional[str], Optional[str]]: """Scan text content for secrets using Cycode CLI.""" if not text: return None, None @@ -353,10 +406,17 @@ def _scan_text_for_secrets(ctx: typer.Context, text: str, timeout_ms: int) -> tu 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) + scan_parameters = build_ai_guardrails_scan_parameters(scan_ctx, None, payload, event_type, effective_mode) + return _perform_scan(scan_ctx, [document], scan_parameters, timeout_seconds) -def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) -> tuple[Optional[str], Optional[str]]: +def _scan_path_for_secrets( + ctx: typer.Context, + file_path: str, + policy: dict, + payload: AIHookPayload, + effective_mode: GuardrailsMode, +) -> tuple[Optional[str], Optional[str]]: """Scan a file path for secrets.""" if not file_path or not os.path.isfile(file_path): return None, None @@ -375,4 +435,7 @@ def _scan_path_for_secrets(ctx: typer.Context, file_path: str, policy: dict) -> 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) + scan_parameters = build_ai_guardrails_scan_parameters( + scan_ctx, (file_path,), payload, AiHookEventType.FILE_READ, effective_mode + ) + return _perform_scan(scan_ctx, [document], scan_parameters, timeout_seconds) diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index 1a389d5e..1c0c42b8 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -8,6 +8,7 @@ """ from typing import Annotated, Optional, Union +from uuid import uuid4 import click import typer @@ -125,6 +126,9 @@ def scan_command( return unified_payload = ide_integration.parse_hook_payload(payload) + if not unified_payload.generation_id: + # Not every IDE dialect provides a generation id (e.g. Copilot) + unified_payload.generation_id = str(uuid4()) event_name = unified_payload.event_name logger.debug( 'Processing AI guardrails hook', diff --git a/cycode/cli/apps/ai_guardrails/scan/types.py b/cycode/cli/apps/ai_guardrails/scan/types.py index da42ed23..5d18e07d 100644 --- a/cycode/cli/apps/ai_guardrails/scan/types.py +++ b/cycode/cli/apps/ai_guardrails/scan/types.py @@ -41,3 +41,12 @@ class BlockReason(StrEnum): SECRETS_IN_MCP_ARGS = 'secrets_in_mcp_args' SENSITIVE_PATH = 'sensitive_path' SCAN_FAILURE = 'scan_failure' + + +# The reason each event type yields when a secret is found in it. Also travels with the scan as +# `detection_source`, so the violation and the hook event are labelled from the same vocabulary. +SECRETS_BLOCK_REASON_BY_EVENT_TYPE: dict[AiHookEventType, BlockReason] = { + AiHookEventType.PROMPT: BlockReason.SECRETS_IN_PROMPT, + AiHookEventType.FILE_READ: BlockReason.SECRETS_IN_FILE, + AiHookEventType.MCP_EXECUTION: BlockReason.SECRETS_IN_MCP_ARGS, +} diff --git a/cycode/cli/utils/host_info.py b/cycode/cli/utils/host_info.py index 23737b7a..60a36bed 100644 --- a/cycode/cli/utils/host_info.py +++ b/cycode/cli/utils/host_info.py @@ -1,8 +1,11 @@ import getpass +import os import platform import re import socket import subprocess +import tempfile +from pathlib import Path from typing import Optional from cycode.logger import get_logger @@ -11,6 +14,8 @@ _SUBPROCESS_TIMEOUT_SEC = 5 +_SERIAL_NUMBER_CACHE_FILE_NAME = '.cycode-device-serial' + _PLATFORM_NAMES = {'Darwin': 'macOS', 'Windows': 'Windows', 'Linux': 'Linux'} @@ -93,6 +98,19 @@ def get_last_login_user() -> Optional[str]: def get_serial_number() -> Optional[str]: + # The serial is immutable hardware info, but resolving it shells out (ioreg/WMI) + # and this runs in a fresh process per AI hook event - cache it on disk. + cached = _read_serial_number_cache() + if cached: + return cached + + serial = _resolve_serial_number() + if serial: + _write_serial_number_cache(serial) + return serial + + +def _resolve_serial_number() -> Optional[str]: try: system = platform.system() if system == 'Darwin': @@ -104,6 +122,40 @@ def get_serial_number() -> Optional[str]: return None +def _serial_number_cache_path() -> Path: + # The username suffix avoids collisions on OSes with a shared temp dir + return Path(tempfile.gettempdir()) / f'.cycode-device-serial-{getpass.getuser()}' + + +def _read_serial_number_cache() -> Optional[str]: + try: + return _serial_number_cache_path().read_text(encoding='utf-8').strip() or None + except Exception: + return None + + +def _write_serial_number_cache(serial: str) -> None: + try: + cache_path = _serial_number_cache_path() + + # The serial identifies the machine, and the temp dir is shared, so the cache is created + # readable by its owner alone (what mkstemp does) and moved into place atomically - a hook + # racing another one never reads a half-written cache, and the rename can't be redirected + # by a symlink planted at the destination the way an in-place write could. + file_descriptor, temp_path = tempfile.mkstemp( + dir=cache_path.parent, prefix=f'{_SERIAL_NUMBER_CACHE_FILE_NAME}.' + ) + try: + with os.fdopen(file_descriptor, 'w', encoding='utf-8') as temp_file: + temp_file.write(serial) + os.replace(temp_path, cache_path) + except Exception: + Path(temp_path).unlink(missing_ok=True) + raise + except Exception as e: + logger.debug('Failed to cache serial number', exc_info=e) + + def _get_macos_serial_number() -> Optional[str]: output = _run(['ioreg', '-c', 'IOPlatformExpertDevice', '-d', '2']) if not output: diff --git a/tests/cli/commands/ai_guardrails/scan/test_handlers.py b/tests/cli/commands/ai_guardrails/scan/test_handlers.py index 01f8790c..401482ac 100644 --- a/tests/cli/commands/ai_guardrails/scan/test_handlers.py +++ b/tests/cli/commands/ai_guardrails/scan/test_handlers.py @@ -7,10 +7,14 @@ import pytest import typer +from cycode.cli.apps.ai_guardrails.consts import GuardrailsMode from cycode.cli.apps.ai_guardrails.ides.base import DecisionAction, HookDecision from cycode.cli.apps.ai_guardrails.scan.handlers import ( _perform_scan, _scan_path_for_secrets, + _scan_text_for_secrets, + build_ai_guardrails_scan_parameters, + get_effective_mode, handle_before_mcp_execution, handle_before_read_file, handle_before_submit_prompt, @@ -359,18 +363,26 @@ def test_handle_before_read_file_sensitive_path_scan_disabled_warns( assert call_args.kwargs['block_reason'] == BlockReason.SENSITIVE_PATH -def test_scan_path_for_secrets_directory(mock_ctx: MagicMock, default_policy: dict[str, Any], fs: Any) -> None: +def test_scan_path_for_secrets_directory( + mock_ctx: MagicMock, default_policy: dict[str, Any], mock_payload: AIHookPayload, fs: Any +) -> None: """Test that _scan_path_for_secrets returns (None, None) for directories.""" fs.create_dir('/path/to/some_directory') - result = _scan_path_for_secrets(mock_ctx, '/path/to/some_directory', default_policy) + result = _scan_path_for_secrets( + mock_ctx, '/path/to/some_directory', default_policy, payload=mock_payload, effective_mode=GuardrailsMode.BLOCK + ) assert result == (None, None) @patch('cycode.cli.apps.ai_guardrails.scan.handlers._perform_scan') def test_scan_path_for_secrets_skips_path_configured_in_exclusions( - mock_perform_scan: MagicMock, mock_ctx: MagicMock, default_policy: dict[str, Any], fs: Any + mock_perform_scan: MagicMock, + mock_ctx: MagicMock, + default_policy: dict[str, Any], + mock_payload: AIHookPayload, + fs: Any, ) -> None: """Test that a path ignored via `cycode ignore --by-path` is not scanned.""" # `cycode ignore --by-path` stores absolute paths; on Windows that includes the drive prefix @@ -383,7 +395,9 @@ def test_scan_path_for_secrets_skips_path_configured_in_exclusions( 'cycode.cli.files_collector.file_excluder.configuration_manager.get_exclusions_by_scan_type', return_value={'paths': [excluded_dir]}, ): - result = _scan_path_for_secrets(mock_ctx, file_path, default_policy) + result = _scan_path_for_secrets( + mock_ctx, file_path, default_policy, payload=mock_payload, effective_mode=GuardrailsMode.BLOCK + ) assert result == (None, None) mock_perform_scan.assert_not_called() @@ -512,3 +526,62 @@ def test_handle_before_mcp_execution_scan_disabled( assert result == HookDecision.allow(AiHookEventType.MCP_EXECUTION) mock_scan.assert_not_called() + + +def test_get_effective_mode_block_only_when_both_mode_and_action_block() -> None: + """The event blocks only when both the global mode and the per-guardrail action are block.""" + assert get_effective_mode({'mode': 'block'}, {'action': 'block'}) == GuardrailsMode.BLOCK + assert get_effective_mode({'mode': 'block'}, {'action': 'warn'}) == GuardrailsMode.REPORT + assert get_effective_mode({'mode': 'warn'}, {'action': 'block'}) == GuardrailsMode.REPORT + assert get_effective_mode({'mode': 'warn'}, {'action': 'warn'}) == GuardrailsMode.REPORT + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.get_serial_number', return_value='SER-123') +@patch('cycode.cli.apps.ai_guardrails.scan.handlers.get_hostname', return_value='test-host') +def test_build_ai_guardrails_scan_parameters( + mock_hostname: MagicMock, mock_serial: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload +) -> None: + """The built scan parameters embed the full hook context alongside the standard scan parameters.""" + mock_ctx.info_name = 'ai_guardrails' + + params = build_ai_guardrails_scan_parameters( + mock_ctx, None, mock_payload, AiHookEventType.PROMPT, effective_mode=GuardrailsMode.REPORT + ) + + assert params['command_type'] == 'ai_guardrails' + assert params['metadata']['ai_guardrails'] == { + 'mode': 'report', + 'ide_provider': 'cursor', + 'detection_source': 'secrets_in_prompt', + 'device_id': 'SER-123', + 'device_hostname': 'test-host', + 'conversation_id': 'test-conv-id', + 'generation_id': 'test-gen-id', + 'ide_user_email': 'test@example.com', + 'mcp_server_name': None, + 'mcp_tool_name': None, + } + + +@patch('cycode.cli.apps.ai_guardrails.scan.handlers._perform_scan') +def test_scan_text_for_secrets_injects_ai_guardrails_scan_parameter( + mock_perform_scan: MagicMock, mock_ctx: MagicMock, mock_payload: AIHookPayload +) -> None: + """The scan parameters sent to the server include the ai_guardrails context.""" + mock_ctx.obj['progress_bar'] = MagicMock() + mock_perform_scan.return_value = (None, 'scan-id-123') + + _scan_text_for_secrets( + mock_ctx, + 'some text', + 1000, + payload=mock_payload, + event_type=AiHookEventType.PROMPT, + effective_mode=GuardrailsMode.REPORT, + ) + + ai_guardrails = mock_perform_scan.call_args.args[2]['metadata']['ai_guardrails'] + assert ai_guardrails['mode'] == 'report' + assert ai_guardrails['detection_source'] == 'secrets_in_prompt' + assert ai_guardrails['conversation_id'] == 'test-conv-id' + assert ai_guardrails['generation_id'] == 'test-gen-id' From 751159885810e85651ab7a3ab2724e99dcb000ac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 07:40:49 +0300 Subject: [PATCH 337/347] Bump docker/setup-buildx-action from 4.0.0 to 4.2.0 (#513) 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 1266dcbf..92e3495e 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -64,7 +64,7 @@ jobs: uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - name: Login to Docker Hub if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') }} From 7f656730fe8caacbd01f93616141d30924359143 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 08:14:06 +0300 Subject: [PATCH 338/347] Bump actions/checkout from 6.0.2 to 7.0.1 (#511) 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 2b81f2cc..56478c83 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -48,7 +48,7 @@ jobs: uploads.github.com - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 92e3495e..f0fb8a1d 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 2dee645b..7e8a6c4b 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -28,7 +28,7 @@ jobs: *.sigstore.dev - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1bc04888..0fc97b12 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: *.sigstore.dev - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 2c1cd8c6..78d393fb 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -21,7 +21,7 @@ jobs: pypi.org - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a9f2bc1c..84387453 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -23,7 +23,7 @@ jobs: *.ingest.us.sentry.io - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index b4eb3e48..6523c391 100644 --- a/.github/workflows/tests_full.yml +++ b/.github/workflows/tests_full.yml @@ -36,7 +36,7 @@ jobs: *.ingest.us.sentry.io - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 From 2dd03295a1de6cb2621662be532d9d460f59fe40 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:13:07 +0300 Subject: [PATCH 339/347] Bump docker/login-action from 4.1.0 to 4.5.0 (#510) 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 f0fb8a1d..156914b6 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -68,7 +68,7 @@ jobs: - name: Login to Docker Hub if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') }} - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0 with: username: ${{ secrets.DOCKERHUB_USER }} password: ${{ secrets.DOCKERHUB_PASSWORD }} From 76d03f5f8d1111b8d1f97e1ac6ece149a1ce675a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:22:17 +0300 Subject: [PATCH 340/347] Bump actions/setup-python from 6.3.0 to 7.0.0 (#509) 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 56478c83..f1600611 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -61,7 +61,7 @@ jobs: - name: Set up Python 3.13 id: setup-python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.13' diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 156914b6..16782d7b 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -31,7 +31,7 @@ jobs: git checkout ${{ steps.latest_tag.outputs.LATEST_TAG }} - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.9' diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 7e8a6c4b..8f00fe49 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@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.9' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0fc97b12..e43f89eb 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@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.9' diff --git a/.github/workflows/ruff.yml b/.github/workflows/ruff.yml index 78d393fb..41cade54 100644 --- a/.github/workflows/ruff.yml +++ b/.github/workflows/ruff.yml @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Setup Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: 3.9 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 84387453..c97318f0 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.9' diff --git a/.github/workflows/tests_full.yml b/.github/workflows/tests_full.yml index 6523c391..7e4de5ef 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@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python-version }} From 90600567db67f1d2aaf3fbd5d02e65e7882845aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:33:41 +0300 Subject: [PATCH 341/347] Bump pypa/gh-action-pypi-publish from 1.14.0 to 1.14.1 (#512) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/pre_release.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pre_release.yml b/.github/workflows/pre_release.yml index 8f00fe49..35649497 100644 --- a/.github/workflows/pre_release.yml +++ b/.github/workflows/pre_release.yml @@ -74,4 +74,4 @@ jobs: run: poetry build - name: Publish a Python distribution to PyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e43f89eb..cc5cbe21 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,4 +73,4 @@ jobs: run: poetry build - name: Publish a Python distribution to PyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 + uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 From e62b5337802f141897f19753ea4c8224f70a1d48 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 10:45:48 +0300 Subject: [PATCH 342/347] Bump tenacity from 9.0.0 to 9.1.2 (#507) 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 3a39aaa5..d1734454 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1822,14 +1822,14 @@ full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart [[package]] name = "tenacity" -version = "9.0.0" +version = "9.1.2" description = "Retry code until it succeeds" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" 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"}, + {file = "tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138"}, + {file = "tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb"}, ] [package.extras] @@ -2027,4 +2027,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "09f70b525d7ba0c84e1a209e4ec1359d52a5e7901869c2b738abf5236f812594" +content-hash = "c854f790a9db6703d7aa2f7a7100e5629dcc3afebdf467b93cfbc9b3616ed4cf" diff --git a/pyproject.toml b/pyproject.toml index 57e59c30..bcd8a8d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ pyjwt = ">=2.8.0,<3.0" rich = ">=13.9.4, <14" patch-ng = "1.19.1" typer = "^0.15.3" -tenacity = ">=9.0.0,<9.1.0" +tenacity = ">=9.1.2,<9.2.0" mcp = { version = ">=1.28.1,<2.0.0", markers = "python_version >= '3.10'" } pydantic = ">=2.11.5,<3.0.0" pathvalidate = ">=3.3.1,<4.0.0" From 3b79f9493bf62ba1ffbb181c917f6e486f9fccb3 Mon Sep 17 00:00:00 2001 From: Omer Roth Date: Sun, 2 Aug 2026 14:26:51 +0300 Subject: [PATCH 343/347] CM-70014: update dependencies (Aug 2026) (#514) Co-authored-by: Claude Opus 5 (1M context) --- cycode/cli/apps/scan/commit_range_scanner.py | 5 +- .../files_collector/commit_range_documents.py | 22 +- poetry.lock | 1010 +++++++++++------ pyproject.toml | 8 +- .../test_commit_range_documents.py | 23 +- 5 files changed, 694 insertions(+), 374 deletions(-) diff --git a/cycode/cli/apps/scan/commit_range_scanner.py b/cycode/cli/apps/scan/commit_range_scanner.py index 298af34c..70b7e8e4 100644 --- a/cycode/cli/apps/scan/commit_range_scanner.py +++ b/cycode/cli/apps/scan/commit_range_scanner.py @@ -27,7 +27,7 @@ get_diff_file_content, get_diff_file_path, get_pre_commit_modified_documents, - get_safe_head_reference_for_diff, + get_staged_diff_index, parse_commit_range, ) from cycode.cli.files_collector.documents_walk_ignore import filter_documents_with_cycodeignore @@ -360,8 +360,7 @@ 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'] 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) + _, diff_index = get_staged_diff_index(repo) progress_bar.set_section_length(ScanProgressBarSection.PREPARE_LOCAL_FILES, len(diff_index)) diff --git a/cycode/cli/files_collector/commit_range_documents.py b/cycode/cli/files_collector/commit_range_documents.py index daa5c432..2fb63581 100644 --- a/cycode/cli/files_collector/commit_range_documents.py +++ b/cycode/cli/files_collector/commit_range_documents.py @@ -15,7 +15,7 @@ from cycode.logger import get_logger if TYPE_CHECKING: - from git import Diff, Repo + from git import Diff, DiffIndex, Repo from cycode.cli.utils.progress_bar import BaseProgressBar, ProgressBarSection @@ -47,6 +47,23 @@ def get_safe_head_reference_for_diff(repo: 'Repo') -> str: return consts.GIT_EMPTY_TREE_OBJECT +def get_staged_diff_index(repo: 'Repo') -> tuple[str, 'DiffIndex']: + """Diff the index against HEAD, or against the empty tree in repositories with no commits. + + GitPython only inverts the `R` flag for HEAD, so `R` must be off for the empty tree to keep + staged content showing up as added lines in both cases. + + Args: + repo: Git repository object + + Returns: + The reference that was diffed against, and the resulting diff index + """ + head_reference = get_safe_head_reference_for_diff(repo) + reverse = head_reference == consts.GIT_HEAD_COMMIT_REV + return head_reference, repo.index.diff(head_reference, create_patch=True, R=reverse) + + 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 @@ -411,8 +428,7 @@ def get_pre_commit_modified_documents( diff_documents = [] 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) + head_reference, diff_index = get_staged_diff_index(repo) progress_bar.set_section_length(progress_bar_section, len(diff_index)) for diff in diff_index: progress_bar.update(progress_bar_section) diff --git a/poetry.lock b/poetry.lock index d1734454..44fa8fb6 100644 --- a/poetry.lock +++ b/poetry.lock @@ -20,11 +20,25 @@ description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" groups = ["main"] +markers = "python_version < \"3.14\"" 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 = "annotated-types" +version = "0.8.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0"}, + {file = "annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7"}, +] + [[package]] name = "anyio" version = "4.12.1" @@ -78,111 +92,185 @@ files = [ {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, ] +[[package]] +name = "backports-datetime-fromisoformat" +version = "2.0.3" +description = "Backport of Python 3.11's datetime.fromisoformat" +optional = false +python-versions = ">3" +groups = ["main"] +markers = "python_version < \"3.11\"" +files = [ + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5f681f638f10588fa3c101ee9ae2b63d3734713202ddfcfb6ec6cea0778a29d4"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:cd681460e9142f1249408e5aee6d178c6d89b49e06d44913c8fdfb6defda8d1c"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:ee68bc8735ae5058695b76d3bb2aee1d137c052a11c8303f1e966aa23b72b65b"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8273fe7932db65d952a43e238318966eab9e49e8dd546550a41df12175cc2be4"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39d57ea50aa5a524bb239688adc1d1d824c31b6094ebd39aa164d6cadb85de22"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ac6272f87693e78209dc72e84cf9ab58052027733cd0721c55356d3c881791cf"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:44c497a71f80cd2bcfc26faae8857cf8e79388e3d5fbf79d2354b8c360547d58"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:6335a4c9e8af329cb1ded5ab41a666e1448116161905a94e054f205aa6d263bc"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e2e4b66e017253cdbe5a1de49e0eecff3f66cd72bcb1229d7db6e6b1832c0443"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:43e2d648e150777e13bbc2549cc960373e37bf65bd8a5d2e0cef40e16e5d8dd0"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:4ce6326fd86d5bae37813c7bf1543bae9e4c215ec6f5afe4c518be2635e2e005"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7c8fac333bf860208fd522a5394369ee3c790d0aa4311f515fcc4b6c5ef8d75"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4da5ab3aa0cc293dc0662a0c6d1da1a011dc1edcbc3122a288cfed13a0b45"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:58ea11e3bf912bd0a36b0519eae2c5b560b3cb972ea756e66b73fb9be460af01"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8a375c7dbee4734318714a799b6c697223e4bbb57232af37fbfff88fb48a14c6"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:ac677b1664c4585c2e014739f6678137c8336815406052349c85898206ec7061"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:66ce47ee1ba91e146149cf40565c3d750ea1be94faf660ca733d8601e0848147"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:8b7e069910a66b3bba61df35b5f879e5253ff0821a70375b9daf06444d046fa4"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:a3b5d1d04a9e0f7b15aa1e647c750631a873b298cdd1255687bb68779fe8eb35"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec1b95986430e789c076610aea704db20874f0781b8624f648ca9fb6ef67c6e1"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffe5f793db59e2f1d45ec35a1cf51404fdd69df9f6952a0c87c3060af4c00e32"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:620e8e73bd2595dfff1b4d256a12b67fce90ece3de87b38e1dde46b910f46f4d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4cf9c0a985d68476c1cabd6385c691201dda2337d7453fb4da9679ce9f23f4e7"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:d144868a73002e6e2e6fef72333e7b0129cecdd121aa8f1edba7107fd067255d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e81b26497a17c29595bc7df20bc6a872ceea5f8c9d6537283945d4b6396aec10"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:5ba00ead8d9d82fd6123eb4891c566d30a293454e54e32ff7ead7644f5f7e575"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-macosx_11_0_x86_64.whl", hash = "sha256:24d574cb4072e1640b00864e94c4c89858033936ece3fc0e1c6f7179f120d0a8"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9735695a66aad654500b0193525e590c693ab3368478ce07b34b443a1ea5e824"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63d39709e17eb72685d052ac82acf0763e047f57c86af1b791505b1fec96915d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:1ea2cc84224937d6b9b4c07f5cb7c667f2bde28c255645ba27f8a675a7af8234"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:4024e6d35a9fdc1b3fd6ac7a673bd16cb176c7e0b952af6428b7129a70f72cce"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:5e2dcc94dc9c9ab8704409d86fcb5236316e9dcef6feed8162287634e3568f4c"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fa2de871801d824c255fac7e5e7e50f2be6c9c376fd9268b40c54b5e9da91f42"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:1314d4923c1509aa9696712a7bc0c7160d3b7acf72adafbbe6c558d523f5d491"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-macosx_11_0_x86_64.whl", hash = "sha256:b750ecba3a8815ad8bc48311552f3f8ab99dd2326d29df7ff670d9c49321f48f"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d5117dce805d8a2f78baeddc8c6127281fa0a5e2c40c6dd992ba6b2b367876"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fb35f607bd1cbe37b896379d5f5ed4dc298b536f4b959cb63180e05cacc0539d"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:61c74710900602637d2d145dda9720c94e303380803bf68811b2a151deec75c2"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ece59af54ebf67ecbfbbf3ca9066f5687879e36527ad69d8b6e3ac565d565a62"}, + {file = "backports_datetime_fromisoformat-2.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:d0a7c5f875068efe106f62233bc712d50db4d07c13c7db570175c7857a7b5dbd"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90e202e72a3d5aae673fcc8c9a4267d56b2f532beeb9173361293625fe4d2039"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2df98ef1b76f5a58bb493dda552259ba60c3a37557d848e039524203951c9f06"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7100adcda5e818b5a894ad0626e38118bb896a347f40ebed8981155675b9ba7b"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e410383f5d6a449a529d074e88af8bc80020bb42b402265f9c02c8358c11da5"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2797593760da6bcc32c4a13fa825af183cd4bfd333c60b3dbf84711afca26ef"}, + {file = "backports_datetime_fromisoformat-2.0.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35a144fd681a0bea1013ccc4cd3fd4dc758ea17ee23dca019c02b82ec46fc0c4"}, + {file = "backports_datetime_fromisoformat-2.0.3.tar.gz", hash = "sha256:b58edc8f517b66b397abc250ecc737969486703a66eb97e01e6d51291b1a139d"}, +] + [[package]] name = "certifi" -version = "2026.5.20" +version = "2026.7.22" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["main", "test"] files = [ - {file = "certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897"}, - {file = "certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d"}, + {file = "certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775"}, + {file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"}, ] [[package]] name = "cffi" -version = "2.0.0" +version = "2.1.0" description = "Foreign Function Interface for Python calling C code." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" 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"}, + {file = "cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0"}, + {file = "cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3"}, + {file = "cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0"}, + {file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43"}, + {file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c"}, + {file = "cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd"}, + {file = "cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f"}, + {file = "cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da"}, + {file = "cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc"}, + {file = "cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f"}, + {file = "cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565"}, + {file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c"}, + {file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02"}, + {file = "cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e"}, + {file = "cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479"}, + {file = "cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458"}, + {file = "cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d"}, + {file = "cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f"}, + {file = "cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7"}, + {file = "cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66"}, + {file = "cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe"}, + {file = "cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b"}, + {file = "cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a"}, + {file = "cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384"}, + {file = "cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6"}, + {file = "cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda"}, + {file = "cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b"}, + {file = "cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a"}, + {file = "cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0"}, + {file = "cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224"}, + {file = "cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c"}, + {file = "cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a"}, + {file = "cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2"}, + {file = "cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512"}, + {file = "cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f"}, + {file = "cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a"}, + {file = "cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3"}, + {file = "cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d"}, + {file = "cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d"}, + {file = "cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5"}, + {file = "cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce"}, + {file = "cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326"}, + {file = "cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd"}, + {file = "cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb"}, + {file = "cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804"}, + {file = "cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714"}, + {file = "cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d"}, + {file = "cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056"}, + {file = "cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4"}, + {file = "cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94"}, + {file = "cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76"}, + {file = "cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5"}, + {file = "cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8"}, + {file = "cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c"}, + {file = "cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001"}, + {file = "cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3"}, + {file = "cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0"}, + {file = "cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1"}, + {file = "cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28"}, + {file = "cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629"}, + {file = "cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6"}, + {file = "cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853"}, + {file = "cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda"}, + {file = "cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc"}, + {file = "cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd"}, + {file = "cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f"}, + {file = "cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc"}, + {file = "cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9"}, + {file = "cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b"}, + {file = "cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5"}, + {file = "cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210"}, + {file = "cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9"}, ] [package.dependencies] @@ -190,141 +278,105 @@ pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} [[package]] name = "charset-normalizer" -version = "3.4.7" +version = "3.4.9" 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.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"}, - {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"}, - {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win32.whl", hash = "sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win_amd64.whl", hash = "sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win_arm64.whl", hash = "sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe"}, + {file = "charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5"}, + {file = "charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b"}, ] [[package]] @@ -474,62 +526,59 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""] [[package]] name = "cryptography" -version = "48.0.0" +version = "49.0.0" 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.9" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c"}, - {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5"}, - {file = "cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321"}, - {file = "cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74"}, - {file = "cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4"}, - {file = "cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7"}, - {file = "cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336"}, - {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057"}, - {file = "cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae"}, - {file = "cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c"}, - {file = "cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f"}, - {file = "cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12"}, - {file = "cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a"}, - {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239"}, - {file = "cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c"}, - {file = "cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4"}, - {file = "cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd"}, - {file = "cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8"}, - {file = "cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855"}, - {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b"}, - {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13"}, - {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb"}, - {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355"}, - {file = "cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a"}, - {file = "cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920"}, + {file = "cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68"}, + {file = "cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9"}, + {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f"}, + {file = "cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459"}, + {file = "cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e"}, + {file = "cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866"}, + {file = "cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8"}, + {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3"}, + {file = "cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27"}, + {file = "cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61"}, + {file = "cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8"}, + {file = "cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36"}, + {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e"}, + {file = "cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b"}, + {file = "cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6"}, + {file = "cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6"}, + {file = "cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493"}, ] [package.dependencies] @@ -590,14 +639,14 @@ smmap = ">=3.0.1,<6" [[package]] name = "gitpython" -version = "3.1.50" +version = "3.1.57" 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.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9"}, - {file = "gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc"}, + {file = "gitpython-3.1.57-py3-none-any.whl", hash = "sha256:4ccf7d73c10f5c9e76043fbb2675ac5a1b3ff5b41e648f56bcbed5f63792ecaf"}, + {file = "gitpython-3.1.57.tar.gz", hash = "sha256:c493ec57c0ef6b19743798b6a5af859c71814b524e7e6f97baa2f8e658961488"}, ] [package.dependencies] @@ -606,7 +655,7 @@ typing-extensions = {version = ">=3.10.0.2", markers = "python_version < \"3.10\ [package.extras] doc = ["sphinx (>=7.4.7,<8)", "sphinx-autodoc-typehints", "sphinx_rtd_theme"] -test = ["coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy (==1.18.2) ; python_version >= \"3.9\"", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""] +test = ["basedpyright (==1.39.9) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "coverage[toml]", "ddt (>=1.1.1,!=1.4.3)", "mock ; python_version < \"3.8\"", "mypy (==1.18.2) ; python_version >= \"3.9\"", "pre-commit", "pytest (>=7.3.1)", "pytest-cov", "pytest-instafail", "pytest-mock", "pytest-sugar", "typing-extensions ; python_version < \"3.11\""] [[package]] name = "h11" @@ -730,11 +779,25 @@ description = "brain-dead simple config-ini parsing" optional = false python-versions = ">=3.8" groups = ["test"] +markers = "python_version < \"3.14\"" files = [ {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +groups = ["test"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + [[package]] name = "jsonschema" version = "4.26.0" @@ -797,6 +860,7 @@ description = "Python port of markdown-it. Markdown parsing, done right!" optional = false python-versions = ">=3.8" groups = ["main"] +markers = "python_version < \"3.14\"" 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"}, @@ -815,24 +879,50 @@ 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 = "markdown-it-py" +version = "4.2.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a"}, + {file = "markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins (>=0.5.0)"] +profiling = ["gprof2dot"] +rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "pytest-timeout", "requests"] + [[package]] name = "marshmallow" -version = "3.26.2" +version = "4.0.1" description = "A lightweight library for converting complex datatypes to and from native Python datatypes." optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73"}, - {file = "marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57"}, + {file = "marshmallow-4.0.1-py3-none-any.whl", hash = "sha256:72f14ef346f81269dbddee891bac547dda1501e9e08b6a809756ea3dbb7936a1"}, + {file = "marshmallow-4.0.1.tar.gz", hash = "sha256:e1d860bd262737cb2d34e1541b84cb52c32c72c9474e3fe6f30f137ef8b0d97f"}, ] [package.dependencies] -packaging = ">=17.0" +backports-datetime-fromisoformat = {version = "*", markers = "python_version < \"3.11\""} +typing-extensions = {version = "*", markers = "python_version < \"3.11\""} [package.extras] 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)"] +docs = ["autodocsumm (==0.2.14)", "furo (==2025.7.19)", "sphinx (==8.2.3)", "sphinx-copybutton (==0.5.2)", "sphinx-issues (==5.0.1)", "sphinxext-opengraph (==0.12.0)"] tests = ["pytest", "simplejson"] [[package]] @@ -913,7 +1003,7 @@ version = "26.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" -groups = ["main", "executable", "test"] +groups = ["executable", "test"] files = [ {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, @@ -1147,15 +1237,15 @@ typing-extensions = ">=4.14.1" [[package]] name = "pydantic-settings" -version = "2.14.1" +version = "2.14.2" description = "Settings management using Pydantic" optional = false python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de"}, - {file = "pydantic_settings-2.14.1.tar.gz", hash = "sha256:e874d3bec7e787b0c9958277956ed9b4dd5de6a80e162188fdaff7c5e26fd5fa"}, + {file = "pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440"}, + {file = "pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f"}, ] [package.dependencies] @@ -1199,25 +1289,25 @@ windows-terminal = ["colorama (>=0.4.6)"] [[package]] name = "pyinstaller" -version = "6.20.0" +version = "6.21.0" description = "PyInstaller bundles a Python application and all its dependencies into a single package." optional = false -python-versions = "<3.15,>=3.8" +python-versions = "<3.16,>=3.8" groups = ["executable"] markers = "python_version < \"3.15\"" files = [ - {file = "pyinstaller-6.20.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:bf3be4e1284ee78ddccba5e29f99443a12a7b4673168288ffc4c9d38c6f7b90e"}, - {file = "pyinstaller-6.20.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:72ae9c1fdea134afa791f58bdc9a1934d5c7609753c111e0026bfc272b32b712"}, - {file = "pyinstaller-6.20.0-py3-none-manylinux2014_i686.whl", hash = "sha256:1031bcc307f3fbeffd4e162723e64d46dbf591c82dd0997413afb2a07328b941"}, - {file = "pyinstaller-6.20.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:8df3b3f347659fa2562d8d193a98ad4600133b8b8d07c268df89e4154376750e"}, - {file = "pyinstaller-6.20.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:b0d3cc9dd8120d448459bd3880a12e2f9774c51443af49047801446377999a59"}, - {file = "pyinstaller-6.20.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:03696bb6350177c6bc23bcaf78e71a33c4a89b6754dd90d1be2f318e978c918b"}, - {file = "pyinstaller-6.20.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:6357f1699f6af84f37e7367f031d4f68abdba65543b83990c9e8f5a4cebed0b7"}, - {file = "pyinstaller-6.20.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0ab39c690abad26ba148e8f664f0478acc82a733997f4f22e757774832802da9"}, - {file = "pyinstaller-6.20.0-py3-none-win32.whl", hash = "sha256:9a7637e8e44b4387b13667fdcaac86ab6b29c446c16d34d8401539b81838759c"}, - {file = "pyinstaller-6.20.0-py3-none-win_amd64.whl", hash = "sha256:d588844e890ee80c4365867f98146636e1849bbca8e4284bbf0c809aff0f161a"}, - {file = "pyinstaller-6.20.0-py3-none-win_arm64.whl", hash = "sha256:bd53282c0a73e5c95573e1ddc8e5d564d4932bec91efbaed4dc5fdff9c2ae7f2"}, - {file = "pyinstaller-6.20.0.tar.gz", hash = "sha256:95c5c7e03d5d61e9dfb8ef259c699cf492bb1041beb6dbe83696608cec07347a"}, + {file = "pyinstaller-6.21.0-py3-none-macosx_10_13_universal2.whl", hash = "sha256:327d132389f37912609e01be62810cf96b5aa95b613903e4b8692e0d12fb0eda"}, + {file = "pyinstaller-6.21.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7071d4b094d5b40deeef5fa3d3b98a1b846087f7562b49209663d5f9281fe251"}, + {file = "pyinstaller-6.21.0-py3-none-manylinux2014_i686.whl", hash = "sha256:6b6374d652107dd4a2eeece903ff82bb4045bb5e1006c5a158a6dcdbefe84bf2"}, + {file = "pyinstaller-6.21.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:4e3108b3f02384560da70e39b8bf22b0ad597d02bd68a40d76ea91c1cfa00cad"}, + {file = "pyinstaller-6.21.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:697532279f535ad572bda613db4f821540e235c7854ca6da4d3bf0373f4415ee"}, + {file = "pyinstaller-6.21.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:605169523a6b5ace39f13dfbff21add9f2bc43df99c7daf9394fefb2c45e8b6f"}, + {file = "pyinstaller-6.21.0-py3-none-musllinux_1_1_aarch64.whl", hash = "sha256:5fa56746c1e76f93634d018502301378a2d0c382553d37d8c3c34ff436c12dd1"}, + {file = "pyinstaller-6.21.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:42395ec76df8e8120c36b13339d9db8cab83e316a12839ee303cc00fc941bb74"}, + {file = "pyinstaller-6.21.0-py3-none-win32.whl", hash = "sha256:c6b28d30d8fd99ce162ff3aab5013ed44dbfb747566b1f01b9bed7964d7c14e9"}, + {file = "pyinstaller-6.21.0-py3-none-win_amd64.whl", hash = "sha256:7fae06c494ce0ebfe6bd3055c0e409def884f63af2e3705d06bd431ad9237fc7"}, + {file = "pyinstaller-6.21.0-py3-none-win_arm64.whl", hash = "sha256:f13c95c9c03fb567217135919f93815c305813126780b0ed6e0123cb8acaf025"}, + {file = "pyinstaller-6.21.0.tar.gz", hash = "sha256:bb9fab705983e393a2d1cac77d6972513057ad800215fd861dc15ff5272e98fd"}, ] [package.dependencies] @@ -1226,7 +1316,7 @@ 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 = ">=2026.4" +pyinstaller-hooks-contrib = ">=2026.6" pywin32-ctypes = {version = ">=0.2.1", markers = "sys_platform == \"win32\""} setuptools = ">=42.0.0" @@ -1236,21 +1326,20 @@ hook-testing = ["execnet (>=1.5.0)", "psutil", "pytest (>=2.7.3)"] [[package]] name = "pyinstaller-hooks-contrib" -version = "2026.5" +version = "2026.6" description = "Community maintained hooks for PyInstaller" optional = false python-versions = ">=3.8" groups = ["executable"] markers = "python_version < \"3.15\"" files = [ - {file = "pyinstaller_hooks_contrib-2026.5-py3-none-any.whl", hash = "sha256:ea1535783fbdac4626351709e83f3ea80b681d3a4745763ebb407b5e27342eb9"}, - {file = "pyinstaller_hooks_contrib-2026.5.tar.gz", hash = "sha256:f066dfca8f7c45ff6336c9cf9fe25b4e48bfeb322a1aa24faaedfb8a8d1b0b08"}, + {file = "pyinstaller_hooks_contrib-2026.6-py3-none-any.whl", hash = "sha256:fd13b8ac126b35361175edacd41a0d97080b75dd5f4b594ecefefff969509dd3"}, + {file = "pyinstaller_hooks_contrib-2026.6.tar.gz", hash = "sha256:bef5002c32f4f50bd55b005da12cff64eca8783e7eaf86a06a62410164bab725"}, ] [package.dependencies] importlib_metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} packaging = ">=22.0" -setuptools = ">=42.0.0" [[package]] name = "pyjwt" @@ -1346,46 +1435,47 @@ cli = ["click (>=5.0)"] [[package]] name = "python-multipart" -version = "0.0.30" +version = "0.0.32" description = "A streaming multipart parser for Python" optional = false python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "python_multipart-0.0.30-py3-none-any.whl", hash = "sha256:830964def8c90607ac5daa00514e3987815865713ade8d20febc9177ac0c3c5b"}, - {file = "python_multipart-0.0.30.tar.gz", hash = "sha256:0edfe0475c1f46ddd3ff7785a626f6118af32bdcf359bb21260367313bb32118"}, + {file = "python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23"}, + {file = "python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e"}, ] [[package]] name = "pywin32" -version = "311" -description = "Python for Window Extensions" +version = "312" +description = "Python for Windows Extensions" optional = false -python-versions = "*" +python-versions = ">=3.9" groups = ["main"] markers = "sys_platform == \"win32\" and python_version >= \"3.10\"" 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"}, + {file = "pywin32-312-cp310-cp310-win32.whl", hash = "sha256:772235332b5d1024c696f11cea1ae4be7930f0a8b894bb43db14e3f435f1ff7e"}, + {file = "pywin32-312-cp310-cp310-win_amd64.whl", hash = "sha256:5dbc35d2b5320dc07f25fa31269cfb767471002b17de5eb067d03da68c7cb2db"}, + {file = "pywin32-312-cp310-cp310-win_arm64.whl", hash = "sha256:3020656e34f1cf7faeb7bccd2b84653a607c6ff0c55ada85e6487d61716deabd"}, + {file = "pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c"}, + {file = "pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a"}, + {file = "pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47"}, + {file = "pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b"}, + {file = "pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc"}, + {file = "pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950"}, + {file = "pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c"}, + {file = "pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9"}, + {file = "pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831"}, + {file = "pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b"}, + {file = "pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e"}, + {file = "pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa"}, + {file = "pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed"}, + {file = "pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5"}, + {file = "pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9"}, + {file = "pywin32-312-cp39-cp39-win32.whl", hash = "sha256:d620900033cc7531e50727c3c8333091df5dd3ffe6d68cdca38c03f5821408d5"}, + {file = "pywin32-312-cp39-cp39-win_amd64.whl", hash = "sha256:dc90147579a905b8635e1b0ec6514967dcb07e6e0d9c42f1477feef14cac23bb"}, + {file = "pywin32-312-cp39-cp39-win_arm64.whl", hash = "sha256:02ebca0f0242b75292e218065004310d6a477407c09fa449bfe4f6022bc0c0fc"}, ] [[package]] @@ -1509,6 +1599,7 @@ description = "Python HTTP for Humans." optional = false python-versions = ">=3.9" groups = ["main", "test"] +markers = "python_version < \"3.14\"" files = [ {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, @@ -1524,16 +1615,39 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "requests" +version = "2.34.2" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.10" +groups = ["main", "test"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, + {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, +] + +[package.dependencies] +certifi = ">=2023.5.7" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.26,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] + [[package]] name = "responses" -version = "0.26.1" +version = "0.26.2" description = "A utility library for mocking out the `requests` Python library." optional = false python-versions = ">=3.8" groups = ["test"] files = [ - {file = "responses-0.26.1-py3-none-any.whl", hash = "sha256:8aacc4586eb08fb2208ef64a9eb4258d9b0c6e6f4260845f2f018ab847495345"}, - {file = "responses-0.26.1.tar.gz", hash = "sha256:2eb3218553cc8f79b57d257bac23af5e1bf381f5b9390b1767816f0843e01dc2"}, + {file = "responses-0.26.2-py3-none-any.whl", hash = "sha256:6fdfeabd58e5ec473b98dfe02e6d46d3173bd8dd573eff2ccccf1a05a5135364"}, + {file = "responses-0.26.2.tar.gz", hash = "sha256:9c9259b46a8349197edebf43cfa68a87e1a2802ef503ff8b2fecbabc0b45afd8"}, ] [package.dependencies] @@ -1546,20 +1660,19 @@ tests = ["coverage (>=6.0.0)", "flake8", "mypy", "pytest (>=7.0.0)", "pytest-asy [[package]] name = "rich" -version = "13.9.4" +version = "15.0.0" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" optional = false -python-versions = ">=3.8.0" +python-versions = ">=3.9.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"}, + {file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"}, + {file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"}, ] [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)"] @@ -1571,7 +1684,7 @@ description = "Python bindings to Rust's persistent data structures (rpds)" optional = false python-versions = ">=3.10" groups = ["main"] -markers = "python_version >= \"3.10\"" +markers = "python_version < \"3.14\" and python_version >= \"3.10\"" files = [ {file = "rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288"}, {file = "rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00"}, @@ -1690,6 +1803,133 @@ files = [ {file = "rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84"}, ] +[[package]] +name = "rpds-py" +version = "2026.6.3" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.11" +groups = ["main"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7"}, + {file = "rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da"}, + {file = "rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4"}, + {file = "rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6"}, + {file = "rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93"}, + {file = "rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a"}, + {file = "rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127"}, + {file = "rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804"}, + {file = "rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0"}, + {file = "rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4"}, + {file = "rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa"}, + {file = "rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc"}, + {file = "rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822"}, + {file = "rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed"}, + {file = "rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f"}, + {file = "rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96"}, + {file = "rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223"}, + {file = "rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4"}, + {file = "rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7"}, + {file = "rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d"}, + {file = "rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97"}, + {file = "rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0"}, + {file = "rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80"}, + {file = "rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb"}, + {file = "rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e"}, + {file = "rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77"}, + {file = "rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698"}, + {file = "rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd"}, + {file = "rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d"}, + {file = "rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8"}, + {file = "rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5"}, + {file = "rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2"}, + {file = "rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13"}, + {file = "rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868"}, + {file = "rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187"}, + {file = "rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107"}, + {file = "rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba"}, + {file = "rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369"}, + {file = "rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146"}, + {file = "rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826"}, + {file = "rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4"}, +] + [[package]] name = "ruff" version = "0.15.20" @@ -1725,7 +1965,7 @@ description = "Most extensible Python build backend with support for C/C++ exten optional = false python-versions = ">=3.9" groups = ["executable"] -markers = "python_version < \"3.15\"" +markers = "python_version < \"3.14\"" files = [ {file = "setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb"}, {file = "setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9"}, @@ -1740,6 +1980,28 @@ 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.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.18.*)", "pytest-mypy"] +[[package]] +name = "setuptools" +version = "83.0.0" +description = "Most extensible Python build backend with support for C/C++ extension modules" +optional = false +python-versions = ">=3.10" +groups = ["executable"] +markers = "python_version == \"3.14\"" +files = [ + {file = "setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3"}, + {file = "setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.13.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)", "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 (>=3.4)"] +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.18.*)", "pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""] + [[package]] name = "shellingham" version = "1.5.4" @@ -1778,15 +2040,15 @@ files = [ [[package]] name = "sse-starlette" -version = "3.4.4" +version = "3.4.6" description = "SSE plugin for Starlette" optional = false python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973"}, - {file = "sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0"}, + {file = "sse_starlette-3.4.6-py3-none-any.whl", hash = "sha256:56217ab4c9a9f9c5db7b21e08732d3e7c2b807f45231ad23de0551a24c4a41f6"}, + {file = "sse_starlette-3.4.6.tar.gz", hash = "sha256:725f8a1bd6d26ae1b2c9610c0ef5065dfdd496f3988d28adcf8c4b49dc25c627"}, ] [package.dependencies] @@ -1802,15 +2064,15 @@ uvicorn = ["uvicorn (>=0.34.0)"] [[package]] name = "starlette" -version = "1.2.1" +version = "1.3.1" description = "The little ASGI library that shines." optional = false python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\"" files = [ - {file = "starlette-1.2.1-py3-none-any.whl", hash = "sha256:4de0082d08c8f6764a85a54cf1120d6939507a19905c7768acad2a9f875d2b89"}, - {file = "starlette-1.2.1.tar.gz", hash = "sha256:9b9b5ebb992e67d6093741e63c2f59e4f6fff986f81163c087867bd7b924b3f6"}, + {file = "starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6"}, + {file = "starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0"}, ] [package.dependencies] @@ -1818,7 +2080,7 @@ 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"] +full = ["httpx (>=0.27.0,<0.29.0)", "httpx2 (>=2.0.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] [[package]] name = "tenacity" @@ -1827,6 +2089,7 @@ description = "Retry code until it succeeds" optional = false python-versions = ">=3.9" groups = ["main"] +markers = "python_version < \"3.14\"" files = [ {file = "tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138"}, {file = "tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb"}, @@ -1836,6 +2099,23 @@ files = [ doc = ["reno", "sphinx"] test = ["pytest", "tornado (>=4.5)", "typeguard"] +[[package]] +name = "tenacity" +version = "9.1.4" +description = "Retry code until it succeeds" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55"}, + {file = "tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a"}, +] + +[package.extras] +doc = ["reno", "sphinx"] +test = ["pytest", "tornado (>=4.5)", "typeguard"] + [[package]] name = "tomli" version = "2.4.1" @@ -1926,14 +2206,14 @@ typing-extensions = ">=3.7.4.3" [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" 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"}, + {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, + {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, ] markers = {test = "python_version < \"3.11\""} @@ -1954,14 +2234,14 @@ typing-extensions = ">=4.12.0" [[package]] name = "tzdata" -version = "2026.2" +version = "2026.3" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" groups = ["main"] files = [ - {file = "tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7"}, - {file = "tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10"}, + {file = "tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931"}, + {file = "tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415"}, ] [[package]] @@ -1971,6 +2251,7 @@ description = "HTTP library with thread-safe connection pooling, file post, and optional = false python-versions = ">=3.9" groups = ["main", "test"] +markers = "python_version < \"3.14\"" files = [ {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"}, {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"}, @@ -1982,17 +2263,36 @@ 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 = "urllib3" +version = "2.7.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.10" +groups = ["main", "test"] +markers = "python_version >= \"3.14\"" +files = [ + {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, + {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, +] + +[package.extras] +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" -version = "0.48.0" +version = "0.51.0" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.10" groups = ["main"] markers = "python_version >= \"3.10\" and sys_platform != \"emscripten\"" files = [ - {file = "uvicorn-0.48.0-py3-none-any.whl", hash = "sha256:48097851328b87ec36117d3d575234519eb58c2b22d79666e9bbc6c49a761dad"}, - {file = "uvicorn-0.48.0.tar.gz", hash = "sha256:a5504207195d08c2511bf9125ede5ac4a4b71725d519e758d01dcf0bc2d31c37"}, + {file = "uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b"}, + {file = "uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0"}, ] [package.dependencies] @@ -2001,7 +2301,7 @@ 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.20)", "websockets (>=10.4)"] +standard = ["httptools (>=0.8.0)", "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.20)", "websockets (>=13.0)"] [[package]] name = "zipp" @@ -2027,4 +2327,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "c854f790a9db6703d7aa2f7a7100e5629dcc3afebdf467b93cfbc9b3616ed4cf" +content-hash = "ba9807509e16982bc1c02848ce47c5a149542dc6613dd8518192e79ea4acb351" diff --git a/pyproject.toml b/pyproject.toml index bcd8a8d5..2d2beccc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,13 +36,15 @@ 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,<4.0.0" -gitpython = ">=3.1.50,<3.2.0" +# capped while we support Python 3.9 +marshmallow = ">=4.0.1,<4.1.0" +# floor required by get_staged_diff_index() +gitpython = ">=3.1.51,<3.2.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" -rich = ">=13.9.4, <14" +rich = ">=15.0.0,<16.0.0" patch-ng = "1.19.1" typer = "^0.15.3" tenacity = ">=9.1.2,<9.2.0" diff --git a/tests/cli/files_collector/test_commit_range_documents.py b/tests/cli/files_collector/test_commit_range_documents.py index 999e0e0c..d972144c 100644 --- a/tests/cli/files_collector/test_commit_range_documents.py +++ b/tests/cli/files_collector/test_commit_range_documents.py @@ -16,6 +16,7 @@ collect_commit_range_diff_documents, get_diff_file_path, get_safe_head_reference_for_diff, + get_staged_diff_index, parse_commit_range, parse_pre_push_input, parse_pre_receive_input, @@ -85,12 +86,14 @@ def test_index_diff_works_on_bare_repository(self) -> None: 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) + head_ref, diff_index = get_staged_diff_index(repo) + assert head_ref == consts.GIT_EMPTY_TREE_OBJECT assert len(diff_index) == 1 diff = diff_index[0] assert diff.b_path == 'staged_file.py' + # staged content must be an addition, not a removal + assert b"+print('staged content')" in diff.diff def test_index_diff_works_on_repository_with_commits(self) -> None: """Test that index.diff continues to work on repositories with existing commits.""" @@ -111,14 +114,17 @@ def test_index_diff_works_on_repository_with_commits(self) -> None: 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) + head_ref, diff_index = get_staged_diff_index(repo) 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 + # staged content must be additions, not removals + patches = b''.join(diff.diff for diff in diff_index) + assert b"+print('new file')" in patches + assert b"+print('modified initial')" in patches def test_sequential_operations_on_same_repository(self) -> None: """Test behavior when transitioning from bare to committed repository.""" @@ -129,8 +135,7 @@ def test_sequential_operations_on_same_repository(self) -> None: 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) + head_ref_before, diff_before = get_staged_diff_index(repo) expected_empty_tree = consts.GIT_EMPTY_TREE_OBJECT assert head_ref_before == expected_empty_tree @@ -144,8 +149,7 @@ def test_sequential_operations_on_same_repository(self) -> None: 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) + head_ref_after, diff_after = get_staged_diff_index(repo) assert head_ref_after == consts.GIT_HEAD_COMMIT_REV assert len(diff_after) == 1 @@ -167,8 +171,7 @@ def test_git_mv_pre_commit_scan() -> None: 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) + _, diff_index = get_staged_diff_index(repo) for diff in diff_index: file_path = get_path_by_os(get_diff_file_path(diff, repo=repo)) From 60dab833a2726c4a25d38f905f5d062bf1db24c3 Mon Sep 17 00:00:00 2001 From: Omer Roth Date: Thu, 6 Aug 2026 12:00:36 +0300 Subject: [PATCH 344/347] Upgrade OpenSSL on macOS Builder (#516) --- .github/workflows/build_executable.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index f1600611..dc1d34fe 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -59,6 +59,12 @@ jobs: git checkout $LATEST_TAG echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV + - name: Upgrade OpenSSL on macOS Builder + if: runner.os == 'macOS' + run: | + brew update + brew upgrade openssl@3 + - name: Set up Python 3.13 id: setup-python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 From 26a4bb23583e6d42f54e920e6c319412ba225a82 Mon Sep 17 00:00:00 2001 From: Omer Roth Date: Sun, 9 Aug 2026 16:58:37 +0300 Subject: [PATCH 345/347] CM-70014 cli update packages aug 2026 3 (#517) --- .github/workflows/build_executable.yml | 6 ------ pyinstaller.spec | 22 ++++++++++++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build_executable.yml b/.github/workflows/build_executable.yml index dc1d34fe..94f285ce 100644 --- a/.github/workflows/build_executable.yml +++ b/.github/workflows/build_executable.yml @@ -58,12 +58,6 @@ jobs: LATEST_TAG=$(git describe --tags `git rev-list --tags --max-count=1`) git checkout $LATEST_TAG echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV - - - name: Upgrade OpenSSL on macOS Builder - if: runner.os == 'macOS' - run: | - brew update - brew upgrade openssl@3 - name: Set up Python 3.13 id: setup-python diff --git a/pyinstaller.spec b/pyinstaller.spec index d93766a8..e5be2bc2 100644 --- a/pyinstaller.spec +++ b/pyinstaller.spec @@ -2,6 +2,10 @@ # 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. +import os +import platform +import subprocess + _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 @@ -43,6 +47,24 @@ a = Analysis( hiddenimports=_hiddenimports, ) +if platform.system() == 'Darwin': + # cryptography ships no macOS x86_64 wheel since 46.0.4, so on Intel it is built from source and + # dynamically links Homebrew's OpenSSL 3 (it needs symbols like `SSL_get0_group_name`, added in + # OpenSSL 3.2). PyInstaller also collects the older OpenSSL 3.0.x that ships with the + # setup-python toolcache Python; both land at the same destination name and the toolcache copy + # wins the dedup, which breaks `import cryptography` at runtime. Drop every collected + # libssl/libcrypto and inject Homebrew's, which satisfies both consumers. + try: + openssl_lib = os.path.join( + subprocess.check_output(['brew', '--prefix', 'openssl@3'], text=True).strip(), 'lib' + ) + a.binaries = [b for b in a.binaries if 'libssl' not in b[0] and 'libcrypto' not in b[0]] + for name in ('libssl.3.dylib', 'libcrypto.3.dylib'): + a.binaries.append((name, os.path.join(openssl_lib, name), 'BINARY')) + print(f'Replaced collected OpenSSL dylibs with Homebrew ones from {openssl_lib}') + except Exception as e: + print(f'Warning: Could not override OpenSSL binaries: {e}') + exe_args = [PYZ(a.pure), a.scripts, a.binaries, a.datas] if _ONEDIR_MODE: exe_args = [PYZ(a.pure), a.scripts] From 1e64043e34c3ef60688888f1d7513e6804b6a6b0 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:21:47 +0300 Subject: [PATCH 346/347] CM-70843: fix Copilot hook payloads being skipped once VS Code sends transcript_path (#519) Co-authored-by: Claude Opus 5 (1M context) --- .../apps/ai_guardrails/ides/claude_code.py | 10 +++++++--- cycode/cli/apps/ai_guardrails/ides/copilot.py | 16 ++++++--------- .../ai_guardrails/ides/test_claude_code.py | 20 +++++++++++++++++-- .../ai_guardrails/ides/test_copilot.py | 11 +++++++++- 4 files changed, 41 insertions(+), 16 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py index 1b3f618b..79ba9e8f 100644 --- a/cycode/cli/apps/ai_guardrails/ides/claude_code.py +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -284,9 +284,13 @@ def render_hooks_config(self, async_mode: bool = False) -> dict: def matches_payload(self, raw_payload: dict) -> bool: # transcript_path is a documented Claude Code common field, present on every # hook event. VS Code Copilot emits near-identical payloads (same event names, - # snake_case fields) without it — requiring it keeps those from being - # processed as Claude Code events. - return raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES and 'transcript_path' in raw_payload + # snake_case fields) — Copilot additionally carries a top-level + # timestamp, which Claude Code never sends. + return ( + raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES + and 'transcript_path' in raw_payload + and 'timestamp' not in raw_payload + ) def is_synthetic_prompt(self, raw_payload: dict) -> bool: if raw_payload.get('hook_event_name') != 'UserPromptSubmit': diff --git a/cycode/cli/apps/ai_guardrails/ides/copilot.py b/cycode/cli/apps/ai_guardrails/ides/copilot.py index 2cd6a427..cfb20f0a 100644 --- a/cycode/cli/apps/ai_guardrails/ides/copilot.py +++ b/cycode/cli/apps/ai_guardrails/ides/copilot.py @@ -7,8 +7,9 @@ are rejected by ``matches_payload`` and fall through to the allow-and-skip path. VS Code sends Claude-style payloads (``hook_event_name``, ``tool_name``, -``tool_input``) with structural differences that ``matches_payload`` keys on: -a top-level ISO ``timestamp`` and no ``transcript_path``. Copilot hooks have no +``tool_input``), told apart by the one field Claude Code never sends: a top-level +ISO ``timestamp``. VS Code also sends a ``transcript_path`` of its own once a +workspace has chat history, so that field cannot discriminate. Copilot hooks have no matchers, so ``preToolUse`` fires for every tool; tools we don't scan pass through as raw event names, which match no handler and allow immediately. """ @@ -340,14 +341,9 @@ def entry(command: str) -> dict: } def matches_payload(self, raw_payload: dict) -> bool: - # Structural discrimination, no magic strings: VS Code Copilot events carry - # a top-level ISO timestamp and no transcript_path; real Claude Code events - # always carry transcript_path; Copilot CLI payloads have no hook_event_name. - return ( - raw_payload.get('hook_event_name', '') in _COPILOT_SCAN_EVENT_NAMES - and 'timestamp' in raw_payload - and 'transcript_path' not in raw_payload - ) + # Structural discrimination, no magic strings: Copilot events carry a top-level + # timestamp, Claude Code events never do. + return raw_payload.get('hook_event_name', '') in _COPILOT_SCAN_EVENT_NAMES and 'timestamp' in raw_payload def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: hook_event_name = raw_payload.get('hook_event_name', '') diff --git a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py index 4dcab376..657a7b5c 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py +++ b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py @@ -28,8 +28,9 @@ def test_matches_payload_only_claude_events() -> None: def test_matches_payload_rejects_vscode_copilot_payloads() -> None: - """VS Code Copilot sends the same event names in the same snake_case dialect, - but never a transcript_path — those events must not be claimed as Claude Code.""" + """VS Code Copilot sends the same event names in the same snake_case dialect, and + now a transcript_path of its own — only the top-level timestamp, which Claude Code + never sends, keeps those events from being claimed as Claude Code.""" claude = ClaudeCode() assert ( claude.matches_payload( @@ -48,6 +49,21 @@ def test_matches_payload_rejects_vscode_copilot_payloads() -> None: claude.matches_payload({'timestamp': '2026-07-14T13:32:46.517Z', 'hook_event_name': 'UserPromptSubmit'}) is False ) + # Carrying a transcript_path must not be enough to claim a Copilot event, or the + # same prompt gets processed twice when both integrations are installed. + assert ( + claude.matches_payload( + { + 'timestamp': '2026-08-13T10:55:29.000Z', + 'hook_event_name': 'UserPromptSubmit', + 'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2', + 'cwd': '/Users/user/project', + 'prompt': 'test prompt', + 'transcript_path': '/Users/user/Library/Application Support/Code/User/workspaceStorage/d/t.jsonl', + } + ) + is False + ) def test_is_synthetic_prompt_task_notification() -> None: diff --git a/tests/cli/commands/ai_guardrails/ides/test_copilot.py b/tests/cli/commands/ai_guardrails/ides/test_copilot.py index 8141f74f..c4aec1e1 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_copilot.py +++ b/tests/cli/commands/ai_guardrails/ides/test_copilot.py @@ -27,6 +27,13 @@ 'prompt': 'test prompt', } +# VS Code attaches a per-session transcript_path once the workspace has chat history. +_VSCODE_PROMPT_PAYLOAD_WITH_TRANSCRIPT = { + **_VSCODE_PROMPT_PAYLOAD, + 'cwd': '/Users/user/project', + 'transcript_path': '/Users/user/Library/Application Support/Code/User/workspaceStorage/dummy/t.jsonl', +} + _VSCODE_READ_FILE_PAYLOAD = { 'timestamp': '2026-07-14T13:35:08.758Z', 'hook_event_name': 'PreToolUse', @@ -81,10 +88,12 @@ def test_matches_payload_accepts_vscode_events() -> None: assert copilot.matches_payload(_VSCODE_PROMPT_PAYLOAD) is True assert copilot.matches_payload(_VSCODE_READ_FILE_PAYLOAD) is True assert copilot.matches_payload(_VSCODE_MCP_PAYLOAD) is True + assert copilot.matches_payload(_VSCODE_PROMPT_PAYLOAD_WITH_TRANSCRIPT) is True def test_matches_payload_rejects_claude_code_payloads() -> None: - # Same event names and dialect, but Claude Code always carries transcript_path. + # Same event names and dialect, and both carry transcript_path - only the + # top-level timestamp separates them, and Claude Code never sends one. assert Copilot().matches_payload(_CLAUDE_CODE_PAYLOAD) is False From c5e1f99c03b6037d648a3290fcca243f04d72d59 Mon Sep 17 00:00:00 2001 From: Ilan Lidovski <105583525+Ilanlido@users.noreply.github.com> Date: Mon, 17 Aug 2026 11:52:03 +0300 Subject: [PATCH 347/347] CM-71014 copilot agent dialect (#520) Co-authored-by: Claude Opus 5 (1M context) --- .../apps/ai_guardrails/ides/claude_code.py | 12 +- cycode/cli/apps/ai_guardrails/ides/copilot.py | 164 +++++++++++++----- .../apps/ai_guardrails/scan/scan_command.py | 10 +- .../ai_guardrails/ides/test_claude_code.py | 20 +-- .../ai_guardrails/ides/test_copilot.py | 105 +++++++++-- .../ai_guardrails/test_hooks_manager.py | 4 +- 6 files changed, 214 insertions(+), 101 deletions(-) diff --git a/cycode/cli/apps/ai_guardrails/ides/claude_code.py b/cycode/cli/apps/ai_guardrails/ides/claude_code.py index 79ba9e8f..131e8e1a 100644 --- a/cycode/cli/apps/ai_guardrails/ides/claude_code.py +++ b/cycode/cli/apps/ai_guardrails/ides/claude_code.py @@ -282,15 +282,9 @@ def render_hooks_config(self, async_mode: bool = False) -> dict: } def matches_payload(self, raw_payload: dict) -> bool: - # transcript_path is a documented Claude Code common field, present on every - # hook event. VS Code Copilot emits near-identical payloads (same event names, - # snake_case fields) — Copilot additionally carries a top-level - # timestamp, which Claude Code never sends. - return ( - raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES - and 'transcript_path' in raw_payload - and 'timestamp' not in raw_payload - ) + # transcript_path is a documented Claude Code field, present on every hook event. + # Positive test by design: an absence check breaks silently when a vendor adds a field. + return raw_payload.get('hook_event_name', '') in _CLAUDE_CODE_EVENT_NAMES and 'transcript_path' in raw_payload def is_synthetic_prompt(self, raw_payload: dict) -> bool: if raw_payload.get('hook_event_name') != 'UserPromptSubmit': diff --git a/cycode/cli/apps/ai_guardrails/ides/copilot.py b/cycode/cli/apps/ai_guardrails/ides/copilot.py index cfb20f0a..5f3bc76a 100644 --- a/cycode/cli/apps/ai_guardrails/ides/copilot.py +++ b/cycode/cli/apps/ai_guardrails/ides/copilot.py @@ -1,17 +1,24 @@ -"""GitHub Copilot (VS Code extension) integration for AI guardrails. +"""GitHub Copilot integration for AI guardrails. Hooks are installed in Copilot's native format to ``~/.copilot/hooks/cycode.json`` -(user scope) or ``/.github/hooks/cycode.json`` (repo scope). Both locations -are also read by Copilot CLI and the Copilot cloud coding agent, but only the -VS Code payload dialect is parsed here — CLI payloads (camelCase, no event name) -are rejected by ``matches_payload`` and fall through to the allow-and-skip path. - -VS Code sends Claude-style payloads (``hook_event_name``, ``tool_name``, -``tool_input``), told apart by the one field Claude Code never sends: a top-level -ISO ``timestamp``. VS Code also sends a ``transcript_path`` of its own once a -workspace has chat history, so that field cannot discriminate. Copilot hooks have no -matchers, so ``preToolUse`` fires for every tool; tools we don't scan pass -through as raw event names, which match no handler and allow immediately. +(user scope) or ``/.github/hooks/cycode.json`` (repo scope). One file, but +two runtimes are known to execute it: VS Code's own chat runtime, and the Copilot +agent runtime (Copilot CLI, and VS Code agent sessions). The repo-scope location +is also read by the Copilot cloud coding agent, whose dialect is untested here. + +Both deliver Claude-style payloads (``hook_event_name``, ``tool_name``, +``tool_input``) when the event keys are registered in PascalCase; the agent runtime +answers camelCase keys with its own dialect (``sessionId``, no event name) instead. +Copilot payloads are told apart from Claude Code's by the one field Claude Code +never sends, a top-level ``timestamp``; ``transcript_path`` cannot discriminate, +since VS Code sends one of its own whenever a folder is open. + +The tool vocabulary still differs by runtime — VS Code reads files with +``read_file``/``filePath`` and names MCP tools ``mcp__``, the agent +runtime uses ``Read``/``path`` and ``-`` — so both are accepted. +Copilot hooks have no matchers, so ``PreToolUse`` fires for every tool; tools we +don't scan pass through as raw event names, which match no handler and allow +immediately. """ import json @@ -37,20 +44,36 @@ logger = get_logger('AI Guardrails Copilot') -# Payload dialect (VS Code sends Claude-style PascalCase event names). +# Payload dialect (Claude-style PascalCase event names). _COPILOT_SCAN_EVENT_NAMES = frozenset({'UserPromptSubmit', 'PreToolUse'}) -_READ_FILE_TOOL = 'read_file' -# VS Code names MCP tools `mcp__` (single underscores). + +# Two tool vocabularies reach us through one hooks file: VS Code's own runtime +# names file reads `read_file` with a `filePath` argument, while the Copilot agent +# runtime (Copilot CLI, and VS Code agent sessions) names them `Read` with `path`. +# The names are disjoint, so both are accepted rather than switched between. +_READ_FILE_TOOLS = frozenset({'read_file', 'Read'}) +_READ_PATH_KEYS = ('path', 'filePath') + +# VS Code names MCP tools `mcp__` (single underscores); the agent +# runtime uses `-` with no prefix (its SDK documents that wire form), +# leaving a hyphen as the only marker of an MCP call there. Every built-in agent +# tool observed is lower snake_case (`view`, `glob`, `str_replace`, `ask_user`) or +# PascalCase (`Read`), so this holds for them — but SDK- or custom-agent-registered +# tools may be named freely. A hyphenated custom tool would be scanned as an MCP +# call with no resolvable server: an extra scan, never a missed one, which is the +# safe direction to err for a guardrail. _MCP_TOOL_PREFIX = 'mcp_' +_MCP_AGENT_SEPARATOR = '-' -# Hooks-file dialect (Copilot-native camelCase event names). -_HOOK_EVENTS = ['userPromptSubmitted', 'preToolUse'] +# Hooks-file event keys. Their case selects the agent runtime's payload dialect. +_HOOK_EVENTS = ['UserPromptSubmit', 'PreToolUse'] _COPILOT_HOME_ENV_VAR = 'COPILOT_HOME' _HOOKS_FILE_NAME = 'cycode.json' _REPO_HOOKS_SUBDIR = Path('.github') / 'hooks' _HOOK_TIMEOUT_SEC = 20 _MCP_CONFIG_FILENAME = 'mcp.json' +_AGENT_MCP_CONFIG_FILENAME = 'mcp-config.json' # Plugin sources. CLI installs register in ~/.copilot/config.json and auto-surface # in VS Code; VS Code UI installs register in ~/.vscode/agent-plugins/installed.json; @@ -68,13 +91,9 @@ Path('.claude-plugin') / 'plugin.json', ) -# --event is ignored by the VS Code payload parsing (the payload self-describes) -# but Copilot CLI payloads carry no event name at all — baking the flag in now -# means CLI support won't require customers to re-install hooks. Values use the -# payload-dialect spelling so a future CLI path can inject them straight into -# hook_event_name and reuse the existing parsing. -_SCAN_PROMPT_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide copilot --event UserPromptSubmit' -_SCAN_TOOL_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide copilot --event PreToolUse' +# One command for both events: every runtime self-describes via hook_event_name once +# the events are registered in PascalCase, so --event is no longer passed. +_SCAN_COMMAND = f'{CYCODE_SCAN_PROMPT_COMMAND} --ide copilot' _SESSION_START_COMMAND = f'{CYCODE_SESSION_START_COMMAND} --ide copilot' @@ -253,7 +272,11 @@ def _collect_installed_plugins() -> dict: def _known_mcp_server_names() -> list[str]: - """Config-declared MCP server names: user-level ``mcp.json`` + plugin configs. + """Config-declared MCP server names, across both runtimes' config files. + + VS Code declares them in its user-level ``mcp.json`` under ``servers``; the + agent runtime uses ``~/.copilot/mcp-config.json`` under ``mcpServers``. Both are + read because one hooks file serves both, and plugin configs contribute to either. Best-effort inventory: servers contributed by extensions, ``chat.mcp.discovery`` imports, dev containers, or non-default profiles are not discoverable from disk. @@ -261,6 +284,12 @@ def _known_mcp_server_names() -> list[str]: config = _load_vscode_mcp_config() servers = (config or {}).get('servers') names = list(servers.keys()) if isinstance(servers, dict) else [] + + agent_config = _load_jsonc(_copilot_home() / _AGENT_MCP_CONFIG_FILENAME) or {} + agent_servers = agent_config.get('mcpServers') + if isinstance(agent_servers, dict): + names.extend(agent_servers.keys()) + for plugin in _collect_installed_plugins().values(): names.extend(plugin.get('mcp_server_names') or []) return names @@ -279,22 +308,57 @@ def _server_name_variants(server_name: str) -> set[str]: return {v for v in (server_name, underscored, collapsed) if v} -def split_mcp_tool_name(tool_name: str, server_names: Iterable[str]) -> tuple[Optional[str], Optional[str]]: - """Split ``mcp__`` into ``(server, tool)``. +def _read_file_path(tool_name: str, tool_input: object) -> Optional[str]: + """Path of a file-read tool call, or None when this isn't one. + + The agent runtime reuses its read tool for directory listings, with a payload + identical to a file read, so the path has to be stat-ed to tell them apart — + VS Code has no such ambiguity (`read_file` vs `list_dir`). A path that isn't an + existing file (a directory, or already deleted) has nothing to scan. + """ + if tool_name not in _READ_FILE_TOOLS or not isinstance(tool_input, dict): + return None + + raw_path = next((tool_input[key] for key in _READ_PATH_KEYS if tool_input.get(key)), None) + if not isinstance(raw_path, str): + return None + + try: + if not Path(raw_path).is_file(): + return None + except OSError as e: + logger.debug('Failed to stat read path, %s', {'path': raw_path}, exc_info=e) + return None + return raw_path + - The ```` part is VS Code's sanitized (and possibly truncated) form of - the server's SELF-REPORTED handshake name, not the config key — so matching - against known config names (and their normalized variants) is best-effort. - When nothing matches, return the unsplit remainder as the tool rather than - fabricating a server from a guessed split. +def is_mcp_tool_name(tool_name: str) -> bool: + """Whether a tool name is an MCP call in either runtime's naming scheme.""" + return tool_name.startswith(_MCP_TOOL_PREFIX) or _MCP_AGENT_SEPARATOR in tool_name + + +def split_mcp_tool_name(tool_name: str, server_names: Iterable[str]) -> tuple[Optional[str], Optional[str]]: + """Split an MCP tool name into ``(server, tool)``. + + Handles both naming schemes: VS Code's ``mcp__`` and the agent + runtime's prefix-less ``-``. In the VS Code form the ```` + part is a sanitized (and possibly truncated) form of the server's SELF-REPORTED + handshake name rather than the config key, so matching against known config + names (and their normalized variants) is best-effort. Server names may + themselves contain the separator, hence the longest-match. When nothing + matches, return the unsplit remainder as the tool rather than fabricating a + server from a guessed split. """ - rest = tool_name[len(_MCP_TOOL_PREFIX) :] + if tool_name.startswith(_MCP_TOOL_PREFIX): + rest, separator = tool_name[len(_MCP_TOOL_PREFIX) :], '_' + else: + rest, separator = tool_name, _MCP_AGENT_SEPARATOR best_server = None best_variant_len = -1 for server in server_names: for variant in _server_name_variants(server): - if (rest == variant or rest.startswith(f'{variant}_')) and len(variant) > best_variant_len: + if (rest == variant or rest.startswith(f'{variant}{separator}')) and len(variant) > best_variant_len: best_server = server best_variant_len = len(variant) if best_server is not None: @@ -318,13 +382,17 @@ def settings_path(self, scope: str, repo_path: Optional[Path] = None) -> Path: def render_hooks_config(self, async_mode: bool = False) -> dict: def entry(command: str) -> dict: if async_mode: - # Copilot has no async hook flag; background via shell on unix. The - # explicit <&0 keeps the payload flowing: a bare `cmd &` gets its stdin - # reattached to /dev/null by the shell (job control is off in hooks). - # Windows PowerShell has no trailing-& operator, so it stays sync. + # Copilot has no async hook flag; background via shell on unix. Both + # redirects are load-bearing. `<&0` keeps the payload flowing: a bare + # `cmd &` gets its stdin reattached to /dev/null by the shell (job + # control is off in hooks), so the scan reads nothing and allows. The + # stdout redirect is what actually makes it async: the backgrounded + # child inherits the hook's stdout and the runner waits on that pipe + # for EOF, so without it the scan blocks the response it was meant to + # run behind. Windows PowerShell has no trailing-&, so it stays sync. return { 'type': 'command', - 'bash': f'{command} <&0 &', + 'bash': f'{command} <&0 >/dev/null 2>&1 &', 'powershell': command, 'timeoutSec': _HOOK_TIMEOUT_SEC, } @@ -334,9 +402,9 @@ def entry(command: str) -> dict: return { 'version': 1, 'hooks': { - 'sessionStart': [{'type': 'command', 'command': _SESSION_START_COMMAND}], - 'userPromptSubmitted': [entry(_SCAN_PROMPT_COMMAND)], - 'preToolUse': [entry(_SCAN_TOOL_COMMAND)], + 'SessionStart': [{'type': 'command', 'command': _SESSION_START_COMMAND}], + 'UserPromptSubmit': [entry(_SCAN_COMMAND)], + 'PreToolUse': [entry(_SCAN_COMMAND)], }, } @@ -350,21 +418,21 @@ def parse_hook_payload(self, raw_payload: dict) -> AIHookPayload: tool_name = raw_payload.get('tool_name', '') tool_input = raw_payload.get('tool_input') + read_path = _read_file_path(tool_name, tool_input) + if hook_event_name == 'UserPromptSubmit': canonical_event: Union[AiHookEventType, str] = AiHookEventType.PROMPT - elif hook_event_name == 'PreToolUse' and tool_name == _READ_FILE_TOOL: + elif hook_event_name == 'PreToolUse' and read_path is not None: canonical_event = AiHookEventType.FILE_READ - elif hook_event_name == 'PreToolUse' and tool_name.startswith(_MCP_TOOL_PREFIX): + elif hook_event_name == 'PreToolUse' and is_mcp_tool_name(tool_name): canonical_event = AiHookEventType.MCP_EXECUTION else: - # No matchers in Copilot hooks: preToolUse fires for every tool. Pass + # No matchers in Copilot hooks: PreToolUse fires for every tool. Pass # the raw tool name through — it matches no handler, so scan_command # answers with a neutral allow before any policy/network work. canonical_event = tool_name or hook_event_name - file_path = None - if canonical_event == AiHookEventType.FILE_READ and isinstance(tool_input, dict): - file_path = tool_input.get('filePath') + file_path = read_path if canonical_event == AiHookEventType.FILE_READ else None mcp_server_name = None mcp_tool_name = None diff --git a/cycode/cli/apps/ai_guardrails/scan/scan_command.py b/cycode/cli/apps/ai_guardrails/scan/scan_command.py index 1c0c42b8..5cf5da38 100644 --- a/cycode/cli/apps/ai_guardrails/scan/scan_command.py +++ b/cycode/cli/apps/ai_guardrails/scan/scan_command.py @@ -83,14 +83,6 @@ def scan_command( hidden=True, ), ] = DEFAULT_IDE_NAME, - event: Annotated[ - Optional[str], - typer.Option( - '--event', - help='Hook event that triggered the scan, for IDEs whose payloads omit it (e.g. Copilot CLI).', - hidden=True, - ), - ] = None, ) -> None: """Scan content from AI IDE hooks for secrets. @@ -132,7 +124,7 @@ def scan_command( event_name = unified_payload.event_name logger.debug( 'Processing AI guardrails hook', - extra={'event_name': event_name, 'ide': ide_integration.name, 'cli_event_hint': event}, + extra={'event_name': event_name, 'ide': ide_integration.name}, ) # Resolved before any policy/client work: Copilot hooks have no matchers, so diff --git a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py index 657a7b5c..743bb372 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_claude_code.py +++ b/tests/cli/commands/ai_guardrails/ides/test_claude_code.py @@ -28,9 +28,8 @@ def test_matches_payload_only_claude_events() -> None: def test_matches_payload_rejects_vscode_copilot_payloads() -> None: - """VS Code Copilot sends the same event names in the same snake_case dialect, and - now a transcript_path of its own — only the top-level timestamp, which Claude Code - never sends, keeps those events from being claimed as Claude Code.""" + """VS Code Copilot sends the same event names in the same snake_case dialect, so + the documented transcript_path is what keeps its events from being claimed here.""" claude = ClaudeCode() assert ( claude.matches_payload( @@ -49,21 +48,6 @@ def test_matches_payload_rejects_vscode_copilot_payloads() -> None: claude.matches_payload({'timestamp': '2026-07-14T13:32:46.517Z', 'hook_event_name': 'UserPromptSubmit'}) is False ) - # Carrying a transcript_path must not be enough to claim a Copilot event, or the - # same prompt gets processed twice when both integrations are installed. - assert ( - claude.matches_payload( - { - 'timestamp': '2026-08-13T10:55:29.000Z', - 'hook_event_name': 'UserPromptSubmit', - 'session_id': '43cbad91-ea8b-4d4a-9acc-56561421c5d2', - 'cwd': '/Users/user/project', - 'prompt': 'test prompt', - 'transcript_path': '/Users/user/Library/Application Support/Code/User/workspaceStorage/d/t.jsonl', - } - ) - is False - ) def test_is_synthetic_prompt_task_notification() -> None: diff --git a/tests/cli/commands/ai_guardrails/ides/test_copilot.py b/tests/cli/commands/ai_guardrails/ides/test_copilot.py index c4aec1e1..5d5f6d4f 100644 --- a/tests/cli/commands/ai_guardrails/ides/test_copilot.py +++ b/tests/cli/commands/ai_guardrails/ides/test_copilot.py @@ -27,7 +27,9 @@ 'prompt': 'test prompt', } -# VS Code attaches a per-session transcript_path once the workspace has chat history. +# VS Code attaches a per-session transcript_path whenever a folder is open: the +# transcript dir is derived from the extension's workspace storageUri, which is +# undefined only in an empty window. _VSCODE_PROMPT_PAYLOAD_WITH_TRANSCRIPT = { **_VSCODE_PROMPT_PAYLOAD, 'cwd': '/Users/user/project', @@ -60,7 +62,37 @@ 'model': 'auto', } -# Copilot CLI dialect: camelCase, epoch-ms timestamp, no event name, stringified args. +# Agent-runtime payloads (Copilot CLI, and VS Code agent sessions) under PascalCase +# event keys: same Claude-style dialect as VS Code, but a `cwd` and its own tool +# vocabulary (`Read`/`path`, `-`) rather than VS Code's. +_AGENT_PROMPT_PAYLOAD = { + 'cwd': '/Users/user/project', + 'hook_event_name': 'UserPromptSubmit', + 'prompt': 'test prompt', + 'session_id': '826a14c1-cfb5-4946-9618-8b0bb7060466', + 'timestamp': '2026-08-14T08:49:22.000Z', +} + +_AGENT_READ_FILE_PAYLOAD = { + 'cwd': '/Users/user/project', + 'hook_event_name': 'PreToolUse', + 'session_id': '826a14c1-cfb5-4946-9618-8b0bb7060466', + 'timestamp': '2026-08-14T08:51:17.000Z', + 'tool_name': 'Read', + 'tool_input': {'path': '/Users/user/.gitconfig'}, +} + +_AGENT_MCP_PAYLOAD = { + 'cwd': '/Users/user/project', + 'hook_event_name': 'PreToolUse', + 'session_id': '826a14c1-cfb5-4946-9618-8b0bb7060466', + 'timestamp': '2026-08-14T08:51:17.000Z', + 'tool_name': 'gitlab-get_user', + 'tool_input': {'user_id': 'dummy-user'}, +} + +# Stale pre-PascalCase installs still emit this: camelCase, epoch-ms timestamp, +# no event name, stringified args. Rejected — they are corrected on reinstall. _COPILOT_CLI_TOOL_PAYLOAD = { 'sessionId': '826a14c1-cfb5-4946-9618-8b0bb7060466', 'timestamp': 1784038775604, @@ -98,7 +130,7 @@ def test_matches_payload_rejects_claude_code_payloads() -> None: def test_matches_payload_rejects_copilot_cli_payloads() -> None: - # CLI dialect is unsupported until its own parsing lands - must skip fail-open. + # Only reachable from a stale camelCase install; corrected by reinstalling hooks. assert Copilot().matches_payload(_COPILOT_CLI_TOOL_PAYLOAD) is False @@ -122,11 +154,25 @@ def test_parse_prompt_payload() -> None: assert unified.prompt == 'test prompt' -def test_parse_read_file_payload() -> None: - unified = Copilot().parse_hook_payload(_VSCODE_READ_FILE_PAYLOAD) - assert unified.event_name == AiHookEventType.FILE_READ - assert unified.file_path == '/Users/user/.gitconfig' - assert unified.mcp_tool_name is None +def test_parse_read_file_payload(fs: FakeFilesystem) -> None: + fs.create_file('/Users/user/.gitconfig') + # Agent-runtime naming (`Read` + `path`) must map identically to VS Code's + # (`read_file` + `filePath`): one hooks file serves both runtimes. + for payload in (_VSCODE_READ_FILE_PAYLOAD, _AGENT_READ_FILE_PAYLOAD): + unified = Copilot().parse_hook_payload(payload) + assert unified.event_name == AiHookEventType.FILE_READ + assert unified.file_path == '/Users/user/.gitconfig' + assert unified.mcp_tool_name is None + + +def test_parse_read_of_directory_is_not_a_file_read(fs: FakeFilesystem) -> None: + # The agent runtime reuses `Read` for directory listings with an identical + # payload shape, so only a stat separates them. + fs.create_dir('/Users/user/project') + payload = {**_AGENT_READ_FILE_PAYLOAD, 'tool_input': {'path': '/Users/user/project'}} + unified = Copilot().parse_hook_payload(payload) + assert unified.event_name == 'Read' + assert unified.file_path is None def test_parse_mcp_payload_without_known_servers_reports_raw(fs: FakeFilesystem) -> None: @@ -139,6 +185,33 @@ def test_parse_mcp_payload_without_known_servers_reports_raw(fs: FakeFilesystem) assert unified.mcp_arguments == {'user_id': 'dummy-user'} +def test_parse_agent_mcp_payload_uses_hyphenated_naming(fs: FakeFilesystem) -> None: + # The agent runtime names MCP tools `-` with no prefix, and + # declares its servers in its own config rather than VS Code's mcp.json. + fs.create_file( + Path.home() / '.copilot' / 'mcp-config.json', + contents=json.dumps({'mcpServers': {'gitlab': {'command': 'dummy-mcp'}}}), + ) + unified = Copilot().parse_hook_payload(_AGENT_MCP_PAYLOAD) + assert unified.event_name == AiHookEventType.MCP_EXECUTION + assert unified.mcp_server_name == 'gitlab' + assert unified.mcp_tool_name == 'get_user' + assert unified.mcp_arguments == {'user_id': 'dummy-user'} + + +def test_parse_agent_mcp_payload_prefers_longest_hyphenated_server(fs: FakeFilesystem) -> None: + # Server names may themselves contain the separator, so the split must not be + # greedy on the first hyphen. + fs.create_file( + Path.home() / '.copilot' / 'mcp-config.json', + contents=json.dumps({'mcpServers': {'gitlab': {}, 'gitlab-selfhosted': {}}}), + ) + payload = {**_AGENT_MCP_PAYLOAD, 'tool_name': 'gitlab-selfhosted-get_user'} + unified = Copilot().parse_hook_payload(payload) + assert unified.mcp_server_name == 'gitlab-selfhosted' + assert unified.mcp_tool_name == 'get_user' + + def test_parse_mcp_payload_with_known_server_containing_underscores(fs: FakeFilesystem) -> None: fs.create_file( _vscode_mcp_config_path(), @@ -232,21 +305,23 @@ def test_render_hooks_config_sync_uses_cross_platform_command() -> None: rendered = Copilot().render_hooks_config() assert rendered['version'] == 1 - prompt_entry = rendered['hooks']['userPromptSubmitted'][0] - assert prompt_entry['command'] == 'cycode ai-guardrails scan --ide copilot --event UserPromptSubmit' + prompt_entry = rendered['hooks']['UserPromptSubmit'][0] + assert prompt_entry['command'] == 'cycode ai-guardrails scan --ide copilot' assert 'bash' not in prompt_entry - tool_entry = rendered['hooks']['preToolUse'][0] - assert tool_entry['command'] == 'cycode ai-guardrails scan --ide copilot --event PreToolUse' + tool_entry = rendered['hooks']['PreToolUse'][0] + assert tool_entry['command'] == 'cycode ai-guardrails scan --ide copilot' - session_entry = rendered['hooks']['sessionStart'][0] + session_entry = rendered['hooks']['SessionStart'][0] assert session_entry['command'] == 'cycode ai-guardrails session-start --ide copilot' def test_render_hooks_config_async_backgrounds_on_unix() -> None: rendered = Copilot().render_hooks_config(async_mode=True) - tool_entry = rendered['hooks']['preToolUse'][0] - assert tool_entry['bash'].endswith('&') + tool_entry = rendered['hooks']['PreToolUse'][0] + # <&0 keeps the payload (a bare `cmd &` gets stdin from /dev/null and scans nothing); + # the stdout redirect releases the pipe the runner waits on, or it still blocks. + assert tool_entry['bash'].endswith('<&0 >/dev/null 2>&1 &') assert not tool_entry['powershell'].endswith('&') assert 'command' not in tool_entry diff --git a/tests/cli/commands/ai_guardrails/test_hooks_manager.py b/tests/cli/commands/ai_guardrails/test_hooks_manager.py index cf478fcc..1a7b7c2f 100644 --- a/tests/cli/commands/ai_guardrails/test_hooks_manager.py +++ b/tests/cli/commands/ai_guardrails/test_hooks_manager.py @@ -287,7 +287,7 @@ def test_copilot_dedicated_file_install_uninstall_lifecycle(fs: FakeFilesystem) assert success is True saved = json.loads(hooks_path.read_text()) assert saved['version'] == 1 - assert set(saved['hooks']) == {'sessionStart', 'userPromptSubmitted', 'preToolUse'} + assert set(saved['hooks']) == {'SessionStart', 'UserPromptSubmit', 'PreToolUse'} assert all(len(entries) == 1 for entries in saved['hooks'].values()) # Reinstall (also flipping mode) must replace, not duplicate. @@ -295,7 +295,7 @@ def test_copilot_dedicated_file_install_uninstall_lifecycle(fs: FakeFilesystem) assert success is True saved = json.loads(hooks_path.read_text()) assert all(len(entries) == 1 for entries in saved['hooks'].values()) - assert saved['hooks']['preToolUse'][0]['bash'].endswith('&') + assert saved['hooks']['PreToolUse'][0]['bash'].endswith('&') # Uninstall deletes the emptied dedicated file rather than leaving a husk. success, _ = uninstall_hooks(copilot)