From 85d950add69f564597f1850a68565d26009c6007 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Wed, 17 Sep 2025 13:11:49 +0200 Subject: [PATCH 1/3] CM-52972 - Add pre-push hook support --- .pre-commit-hooks.yaml | 21 ++ README.md | 123 +++++++- 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 | 8 +- .../files_collector/commit_range_documents.py | 63 ++++ .../user_settings/configuration_manager.py | 44 ++- .../test_commit_range_documents.py | 275 ++++++++++++++++++ 10 files changed, 603 insertions(+), 14 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..5f8a35b0 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 @@ -278,6 +281,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.4.2 + 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 +820,91 @@ 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.4.2 + 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.4.2 + 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 + +#### 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..e53fd2dd 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -240,7 +240,13 @@ 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 +# 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..0d268082 100644 --- a/cycode/cli/files_collector/commit_range_documents.py +++ b/cycode/cli/files_collector/commit_range_documents.py @@ -211,6 +211,69 @@ 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 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 + """ + 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 = ['origin/main', 'origin/master', 'main', 'master'] + + merge_base = None + for default_branch in default_branches: + try: + merge_base = repo.git.merge_base(local_object_name, 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..fdd73084 100644 --- a/tests/cli/files_collector/test_commit_range_documents.py +++ b/tests/cli/files_collector/test_commit_range_documents.py @@ -2,13 +2,18 @@ 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 ( + 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 +341,273 @@ 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 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_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 5cc8e5ccf15fe5ac47e986c1a687124a4fbf1b74 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 19 Sep 2025 10:07:33 +0200 Subject: [PATCH 2/3] improve default branch detection --- README.md | 16 ++ cycode/cli/consts.py | 1 + .../files_collector/commit_range_documents.py | 62 +++++- .../test_commit_range_documents.py | 194 ++++++++++++++++++ 4 files changed, 272 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5f8a35b0..de40337a 100644 --- a/README.md +++ b/README.md @@ -888,6 +888,22 @@ The pre-push hook: - 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: diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index e53fd2dd..7384e33e 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -245,6 +245,7 @@ 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 diff --git a/cycode/cli/files_collector/commit_range_documents.py b/cycode/cli/files_collector/commit_range_documents.py index 0d268082..3d527498 100644 --- a/cycode/cli/files_collector/commit_range_documents.py +++ b/cycode/cli/files_collector/commit_range_documents.py @@ -232,6 +232,62 @@ def parse_pre_push_input() -> str: 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. @@ -240,18 +296,22 @@ def calculate_pre_push_commit_range(push_update_details: str) -> Optional[str]: 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 = ['origin/main', 'origin/master', 'main', 'master'] + 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) diff --git a/tests/cli/files_collector/test_commit_range_documents.py b/tests/cli/files_collector/test_commit_range_documents.py index fdd73084..568b1bec 100644 --- a/tests/cli/files_collector/test_commit_range_documents.py +++ b/tests/cli/files_collector/test_commit_range_documents.py @@ -10,6 +10,7 @@ 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, @@ -390,6 +391,114 @@ def test_parse_whitespace_only_input_raises_error(self) -> None: 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.""" @@ -501,6 +610,91 @@ def test_calculate_range_with_origin_main_as_merge_base(self) -> None: 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): From d179d7fce11a59bba83d57c9659c13edce160592 Mon Sep 17 00:00:00 2001 From: Ilya Siamionau Date: Fri, 19 Sep 2025 10:57:11 +0200 Subject: [PATCH 3/3] bumps version in pre-commit configs --- README.md | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index de40337a..30f31b9d 100644 --- a/README.md +++ b/README.md @@ -236,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: @@ -248,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: @@ -290,7 +286,7 @@ To install the pre-push hook in addition to or instead of the pre-commit hook: ```yaml repos: - repo: https://github.com/cycodehq/cycode-cli - rev: v3.4.2 + rev: v3.5.0 hooks: - id: cycode-pre-push stages: [pre-push] @@ -844,7 +840,7 @@ To set up the pre-push hook using the pre-commit framework: ```yaml repos: - repo: https://github.com/cycodehq/cycode-cli - rev: v3.4.2 + rev: v3.5.0 hooks: - id: cycode-pre-push stages: [pre-push] @@ -855,7 +851,7 @@ To set up the pre-push hook using the pre-commit framework: ```yaml repos: - repo: https://github.com/cycodehq/cycode-cli - rev: v3.4.2 + rev: v3.5.0 hooks: - id: cycode-pre-push # Secrets scan stages: [pre-push]