Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,23 @@
entry: cycode
args: [ '-o', 'text', '--no-progress-meter', 'scan', '-t', 'secret', 'pre-push' ]
stages: [pre-push]
always_run: true
pass_filenames: false
- 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]
always_run: true
pass_filenames: false
- 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]
always_run: true
pass_filenames: false
23 changes: 11 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ Perform the following steps to install the pre-commit hook:
```yaml
repos:
- repo: https://github.com/cycodehq/cycode-cli
rev: v3.5.0
rev: v3.25.0
hooks:
- id: cycode
stages: [pre-commit]
Expand All @@ -269,7 +269,7 @@ Perform the following steps to install the pre-commit hook:
```yaml
repos:
- repo: https://github.com/cycodehq/cycode-cli
rev: v3.5.0
rev: v3.25.0
hooks:
- id: cycode
stages: [pre-commit]
Expand Down Expand Up @@ -308,7 +308,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.5.0
rev: v3.25.0
hooks:
- id: cycode-pre-push
stages: [pre-push]
Expand Down Expand Up @@ -1109,7 +1109,7 @@ To set up the pre-push hook using the pre-commit framework:
```yaml
repos:
- repo: https://github.com/cycodehq/cycode-cli
rev: v3.5.0
rev: v3.25.0
hooks:
- id: cycode-pre-push
stages: [pre-push]
Expand All @@ -1120,7 +1120,7 @@ To set up the pre-push hook using the pre-commit framework:
```yaml
repos:
- repo: https://github.com/cycodehq/cycode-cli
rev: v3.5.0
rev: v3.25.0
hooks:
- id: cycode-pre-push # Secrets scan
stages: [pre-push]
Expand All @@ -1146,16 +1146,15 @@ To set up the pre-push hook using the pre-commit framework:

#### 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
The hook scans every commit being pushed, one commit at a time, so a secret added in one commit and removed in a later one is still caught. When installed through the pre-commit framework, the pushed range is taken from the framework: new commits since the remote branch for existing branches, and the commits not yet on the remote for new branches (all commits when pushing to an empty remote). When run from a hand-written `.git/hooks/pre-push`, the range is read from git's hook input, and new branches are scanned from their merge base with the default branch (see below).

Pushes with nothing to scan, such as tags, branch deletions, and up-to-date pushes, pass without a scan. If the hook cannot determine what is being pushed, it fails and blocks the push instead of passing silently.

The Cycode pre-push hooks set `always_run: true` and `pass_filenames: false`, because the scan covers the pushed commits rather than a list of files. Without `always_run`, the pre-commit framework skips the hook when the pushed commits add up to no changed files, such as a secret added in one commit and deleted in the next. Without `pass_filenames: false`, the framework passes changed file names the scan does not use, and on large pushes it splits them into batches and runs the full scan once per batch. If you define the hook yourself (for example, as a `repo: local` hook), set both options.

#### Smart Default Branch Detection

The pre-push hook intelligently detects the default branch for merge base calculation using this priority order:
When run from a hand-written hook, the pre-push scan 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
Expand Down
37 changes: 27 additions & 10 deletions cycode/cli/apps/scan/pre_push/pre_push_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@
)
from cycode.cli.config import configuration_manager
from cycode.cli.console import console
from cycode.cli.exceptions.custom_exceptions import PrePushInputNotFoundError
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,
get_pre_commit_framework_push_range,
is_invoked_by_pre_commit_framework,
parse_pre_push_input,
)
from cycode.cli.logger import logger
Expand Down Expand Up @@ -43,17 +46,8 @@ def pre_push_command(
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()
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)
commit_range = _get_pre_push_commit_range()
if not commit_range:
logger.info(
'No new commits found for pushed branch, %s',
{'push_update_details': push_update_details},
)
return

scan_commit_range(
Expand All @@ -67,3 +61,26 @@ def pre_push_command(
console.print(consts.PRE_RECEIVE_AND_PUSH_REMEDIATION_MESSAGE)
except Exception as e:
handle_scan_exception(ctx, e)


def _get_pre_push_commit_range() -> Optional[str]:
commit_range = get_pre_commit_framework_push_range()
if commit_range:
logger.debug('Using push details from the pre-commit framework, %s', {'commit_range': commit_range})
return commit_range

if is_invoked_by_pre_commit_framework():
# the framework consumed git's stdin but did not tell us what is being pushed
raise PrePushInputNotFoundError

push_update_details = parse_pre_push_input()
if not push_update_details:
# git runs the hook with empty input when everything is up-to-date
logger.info('No pre-push input found, nothing to scan')
return None

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 commit_range
15 changes: 13 additions & 2 deletions cycode/cli/apps/scan/scan_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
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.cli.utils.path_utils import concat_unique_id, get_path_by_os, normalize_file_path
from cycode.cyclient.models import (
Detection,
DetectionSchema,
Expand All @@ -28,8 +28,15 @@
def _get_document_by_file_name(
documents: list[Document], file_name: str, unique_id: Optional[str] = None
) -> Optional[Document]:
normalized_file_name = normalize_file_path(file_name)
for document in documents:
if normalize_file_path(document.path) == normalize_file_path(file_name) and document.unique_id == unique_id:
if normalize_file_path(document.path) == normalized_file_name and document.unique_id == unique_id:
return document

if (
document.unique_id
and normalize_file_path(concat_unique_id(document.path, document.unique_id)) == normalized_file_name
):
return document

return None
Expand All @@ -50,6 +57,10 @@ def _get_document_detections(
)

document = _get_document_by_file_name(documents_to_scan, file_name, commit_id)
if document is None:
logger.debug('Failed to find the document of the violated file, %s', {'file_name': file_name})
document = Document(file_name, '', unique_id=commit_id)

document_detections.append(DocumentDetections(document=document, detections=detections_per_file.detections))

return document_detections
Expand Down
10 changes: 10 additions & 0 deletions cycode/cli/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
PRE_COMMIT_COMMAND_SCAN_TYPE_OLD = 'pre_commit'
PRE_RECEIVE_COMMAND_SCAN_TYPE = 'pre-receive'
PRE_RECEIVE_COMMAND_SCAN_TYPE_OLD = 'pre_receive'
PRE_PUSH_COMMAND_SCAN_TYPE = 'pre-push'
PRE_PUSH_COMMAND_SCAN_TYPE_OLD = 'pre_push'
COMMIT_HISTORY_COMMAND_SCAN_TYPE = 'commit-history'
COMMIT_HISTORY_COMMAND_SCAN_TYPE_OLD = 'commit_history'

Expand Down Expand Up @@ -193,6 +195,8 @@
PRE_COMMIT_COMMAND_SCAN_TYPE_OLD,
PRE_RECEIVE_COMMAND_SCAN_TYPE,
PRE_RECEIVE_COMMAND_SCAN_TYPE_OLD,
PRE_PUSH_COMMAND_SCAN_TYPE,
PRE_PUSH_COMMAND_SCAN_TYPE_OLD,
COMMIT_HISTORY_COMMAND_SCAN_TYPE,
COMMIT_HISTORY_COMMAND_SCAN_TYPE_OLD,
]
Expand Down Expand Up @@ -311,6 +315,12 @@
GIT_PUSH_OPTION_COUNT_ENV_VAR_NAME = 'GIT_PUSH_OPTION_COUNT'
GIT_PUSH_OPTION_ENV_VAR_PREFIX = 'GIT_PUSH_OPTION_'

# the pre-commit framework consumes git's pre-push stdin and exposes the push details via env vars instead
PRE_COMMIT_FRAMEWORK_ENV_VAR_NAME = 'PRE_COMMIT'
PRE_COMMIT_FROM_REF_ENV_VAR_NAME = 'PRE_COMMIT_FROM_REF'
PRE_COMMIT_TO_REF_ENV_VAR_NAME = 'PRE_COMMIT_TO_REF'
PRE_COMMIT_REMOTE_BRANCH_ENV_VAR_NAME = 'PRE_COMMIT_REMOTE_BRANCH'

SKIP_SCAN_FLAG = 'skip-cycode-scan'
VERBOSE_SCAN_FLAG = 'verbose'

Expand Down
5 changes: 5 additions & 0 deletions cycode/cli/exceptions/custom_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ def __str__(self) -> str:
return self.error_message


class PrePushInputNotFoundError(CycodeError):
def __str__(self) -> str:
return 'Neither git pre-push input nor pre-commit framework push details were found'


class AuthProcessError(CycodeError):
def __init__(self, error_message: str) -> None:
self.error_message = error_message
Expand Down
6 changes: 6 additions & 0 deletions cycode/cli/exceptions/handle_scan_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ def handle_scan_exception(ctx: typer.Context, err: Exception, *, return_exceptio
message='File collection failed. '
'Use --no-restore to skip dependency restoration, or fix the underlying issue.',
),
custom_exceptions.PrePushInputNotFoundError: CliError(
soft_fail=False,
code='pre_push_input_not_found',
message='Could not determine which commits are being pushed, so nothing was scanned. '
'Run this command from a git pre-push hook',
),
custom_exceptions.TfplanKeyError: CliError(
soft_fail=True,
code='key_error',
Expand Down
37 changes: 34 additions & 3 deletions cycode/cli/files_collector/commit_range_documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,11 @@ def collect_commit_range_diff_documents(

repo = git_proxy.get_repo(path)

normalized_commit_range = normalize_commit_range(commit_range, path)
if commit_range == consts.COMMIT_RANGE_ALL_COMMITS:
# everything reachable from HEAD, including the root commit
normalized_commit_range = consts.GIT_HEAD_COMMIT_REV
else:
normalized_commit_range = normalize_commit_range(commit_range, path)

total_commits_count = int(repo.git.rev_list('--count', normalized_commit_range))
logger.debug(
Expand All @@ -104,8 +108,10 @@ def collect_commit_range_diff_documents(

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)
if commit.parents:
diff_index = commit.diff(commit.parents[0], create_patch=True, R=True)
else:
diff_index = commit.diff(git_proxy.get_null_tree(), create_patch=True)
for diff in diff_index:
commit_documents_to_scan.append(
Document(
Expand Down Expand Up @@ -264,6 +270,31 @@ def parse_pre_push_input() -> Optional[str]:
return pre_push_input.splitlines()[0]


def is_invoked_by_pre_commit_framework() -> bool:
return os.getenv(consts.PRE_COMMIT_FRAMEWORK_ENV_VAR_NAME) == '1'


def get_pre_commit_framework_push_range() -> Optional[str]:
"""Get the commit range to scan when invoked as a pre-push hook by the pre-commit framework.

The pre-commit framework reads git's pre-push stdin itself and never forwards it to hooks.
Instead, it exposes the already resolved push details via environment variables.

Returns:
Commit range string for scanning, or None if not invoked by the pre-commit framework
"""
from_ref = os.getenv(consts.PRE_COMMIT_FROM_REF_ENV_VAR_NAME)
to_ref = os.getenv(consts.PRE_COMMIT_TO_REF_ENV_VAR_NAME)
if from_ref and to_ref:
return f'{from_ref}..{to_ref}'

# the framework omits the refs when the pushed history includes the root commit
if os.getenv(consts.PRE_COMMIT_REMOTE_BRANCH_ENV_VAR_NAME):
return consts.COMMIT_RANGE_ALL_COMMITS

return None


def _read_hook_input_from_stdin() -> str:
"""Read input from stdin when called from a hook.

Expand Down
3 changes: 2 additions & 1 deletion cycode/cli/printers/utils/code_snippet_syntax.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@ def _get_code_snippet_syntax_from_git_diff(
detection_position = detection_details.get('start_position', -1)
violation_length = detection_details.get('length', -1)

line_content = document.content.splitlines()[detection_line]
document_content_lines = document.content.splitlines()
line_content = document_content_lines[detection_line] if 0 <= detection_line < len(document_content_lines) else ''
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]
Expand Down
Loading
Loading