Skip to content
Open
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
43 changes: 37 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,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)
1. [Local Diff Scanning (IDE Integrations)](#local-diff-scanning-ide-integrations)
6. [Pre-Push Scan](#pre-push-scan)
2. [Scan Results](#scan-results)
1. [Show/Hide Secrets](#showhide-secrets)
Expand Down Expand Up @@ -798,12 +799,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 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 |
| 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) | Scan content that was not committed yet; also supports local diff scanning via flags (IDE-friendly) |
| [repository](#repository-scan) | Scan git repository including its history |

### Options

Expand Down Expand Up @@ -1085,6 +1086,36 @@ After installing the pre-commit hook, you may occasionally wish to skip scanning
SKIP=cycode git commit -m <your commit message>`
```

The following options are available for use with this command:

| Option | Description |
|-----------------------|---------------------------------------------------------------------------------------------------------------------|
| `-b, --base-ref TEXT` | Git ref (commit, branch, or tag) to diff against; defaults to `HEAD`, matching the pre-commit hook behavior |
| `--include-unstaged` | Also scan unstaged changes to tracked files, not just what is staged; off by default |
| `--path PATH` | Optional path(s) to scope the diff scan to; repeatable; defaults to the entire working directory |

#### Local Diff Scanning (IDE Integrations)

`--base-ref` and `--include-unstaged` turn `pre-commit` into a general-purpose **local diff scan**: comparing any commit (default `HEAD`) against your current working directory — including edits that aren't staged yet. This is intended for IDE plugins and other tools that need continuous, real-time feedback as you work, rather than the git hook flow. Combined with `--include-unstaged`, `--path` lets an IDE scope the scan to just the file currently open in the editor.

> [!NOTE]
> Local diff scanning (via these flags) is not available for IaC scans.

**Scan everything currently changed (staged + unstaged) against the last commit:**
```bash
cycode scan pre-commit --include-unstaged
```

**Scan changes against a specific commit or branch:**
```bash
cycode scan pre-commit --include-unstaged --base-ref main
```

**Scan only a specific file (e.g., the file currently open in your IDE):**
```bash
cycode scan pre-commit --include-unstaged --path src/app.py
```

### 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.
Expand Down
3 changes: 2 additions & 1 deletion cycode/cli/apps/scan/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
)
app.command(
name='pre-commit',
short_help='Use this command in pre-commit hook to scan any content that was not committed yet.',
short_help='Use this command in pre-commit hook to scan any content that was not committed yet. '
'Also supports IDE-style local diff scanning via --base-ref/--include-unstaged/--path.',
rich_help_panel=_AUTOMATION_COMMANDS_RICH_HELP_PANEL,
)(pre_commit_command)
app.command(
Expand Down
118 changes: 70 additions & 48 deletions cycode/cli/apps/scan/commit_range_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,23 +24,18 @@
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,
get_staged_diff_index,
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
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,
perform_sca_pre_commit_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,
Expand Down Expand Up @@ -329,84 +324,103 @@ def scan_commit_range(ctx: typer.Context, repo_path: str, commit_range: str, **k
_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)
def _scan_sca_pre_commit(
ctx: typer.Context,
repo_path: str,
base_ref: str = consts.GIT_HEAD_COMMIT_REV,
include_unstaged: bool = False,
paths: Optional[list[str]] = None,
) -> None:
scan_parameters = get_scan_parameters(ctx, (repo_path,))

git_head_documents, pre_committed_documents, _ = get_pre_commit_modified_documents(
from_ref_documents, working_copy_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,
base_ref=base_ref,
include_unstaged=include_unstaged,
paths=paths,
)

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
)
from_ref_documents = excluder.exclude_irrelevant_documents_to_scan(consts.SCA_SCAN_TYPE, from_ref_documents)
working_copy_documents = excluder.exclude_irrelevant_documents_to_scan(consts.SCA_SCAN_TYPE, working_copy_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
from_ref_documents = filter_documents_with_cycodeignore(from_ref_documents, repo_path, is_cycodeignore_allowed)
working_copy_documents = filter_documents_with_cycodeignore(
working_copy_documents, repo_path, is_cycodeignore_allowed
)

perform_sca_pre_hook_range_scan_actions(repo_path, git_head_documents, pre_committed_documents)
perform_sca_pre_commit_scan_actions(repo_path, from_ref_documents, base_ref, working_copy_documents)

_scan_commit_range_documents(
ctx,
git_head_documents,
pre_committed_documents,
from_ref_documents,
working_copy_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']
repo = git_proxy.get_repo(repo_path)
_, diff_index = get_staged_diff_index(repo)

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, repo=repo)),
get_diff_file_content(diff),
is_git_diff_format=True,
)
)
def _scan_secret_pre_commit(
ctx: typer.Context,
repo_path: str,
base_ref: str = consts.GIT_HEAD_COMMIT_REV,
include_unstaged: bool = False,
paths: Optional[list[str]] = None,
) -> None:
# collect_file_contents=False: the secret scan only ever uses diff_documents below, so skip
# building from_ref_documents/working_copy_documents (a disk read per changed file it would
# otherwise discard immediately).
_from_ref_documents, _working_copy_documents, diff_documents = get_pre_commit_modified_documents(
Comment thread
aaron-butler-cy-int marked this conversation as resolved.
progress_bar=ctx.obj['progress_bar'],
progress_bar_section=ScanProgressBarSection.PREPARE_LOCAL_FILES,
repo_path=repo_path,
base_ref=base_ref,
include_unstaged=include_unstaged,
paths=paths,
collect_file_contents=False,
)

documents_to_scan = excluder.exclude_irrelevant_documents_to_scan(consts.SECRET_SCAN_TYPE, documents_to_scan)
diff_documents = excluder.exclude_irrelevant_documents_to_scan(consts.SECRET_SCAN_TYPE, diff_documents)

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)
diff_documents = filter_documents_with_cycodeignore(diff_documents, repo_path, is_cycodeignore_allowed)

scan_documents(ctx, documents_to_scan, get_scan_parameters(ctx), is_git_diff=True)
scan_documents(ctx, diff_documents, get_scan_parameters(ctx, (repo_path,)), is_git_diff=True)


def _scan_sast_pre_commit(ctx: typer.Context, repo_path: str, **_) -> None:
def _scan_sast_pre_commit(
ctx: typer.Context,
repo_path: str,
base_ref: str = consts.GIT_HEAD_COMMIT_REV,
include_unstaged: bool = False,
paths: Optional[list[str]] = None,
**_,
) -> None:
scan_parameters = get_scan_parameters(ctx, (repo_path,))

_, pre_committed_documents, diff_documents = get_pre_commit_modified_documents(
_from_ref_documents, working_copy_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,
base_ref=base_ref,
include_unstaged=include_unstaged,
paths=paths,
)

pre_committed_documents = excluder.exclude_irrelevant_documents_to_scan(
consts.SAST_SCAN_TYPE, pre_committed_documents
working_copy_documents = excluder.exclude_irrelevant_documents_to_scan(
consts.SAST_SCAN_TYPE, working_copy_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
working_copy_documents = filter_documents_with_cycodeignore(
working_copy_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)
_scan_commit_range_documents(ctx, working_copy_documents, diff_documents, scan_parameters=scan_parameters)


_SCAN_TYPE_TO_PRE_COMMIT_HANDLER = {
Expand All @@ -416,10 +430,18 @@ def _scan_sast_pre_commit(ctx: typer.Context, repo_path: str, **_) -> None:
}


def scan_pre_commit(ctx: typer.Context, repo_path: str) -> None:
def scan_pre_commit(
ctx: typer.Context,
repo_path: str,
base_ref: str = consts.GIT_HEAD_COMMIT_REV,
include_unstaged: bool = False,
paths: Optional[list[str]] = None,
) -> 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)
_SCAN_TYPE_TO_PRE_COMMIT_HANDLER[scan_type](
ctx, repo_path, base_ref=base_ref, include_unstaged=include_unstaged, paths=paths
)
logger.debug('Pre-commit scan completed successfully')
86 changes: 84 additions & 2 deletions cycode/cli/apps/scan/pre_commit/pre_commit_command.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,100 @@
import os
from pathlib import Path
from typing import Annotated, Optional

import typer

from cycode.cli import consts
from cycode.cli.apps.scan.commit_range_scanner import scan_pre_commit
from cycode.cli.exceptions.custom_exceptions import ScanPathOutsideRepositoryError, UnresolvedGitRefError
from cycode.cli.exceptions.handle_scan_errors import handle_scan_exception
from cycode.cli.logger import logger
from cycode.cli.utils.git_proxy import git_proxy


def _resolve_repo_root(cwd: str) -> str:
"""Resolve the repository root from `cwd`, which may be any subdirectory of the repo.

`git_proxy.get_repo()` requires an exact match (the root or a `.git` dir) unless told to
search parent directories, so without this, running the command from anywhere but the repo
root raises a misleading "not a git repository" error. The git pre-commit hook framework
always invokes from the repo root, so this is a no-op there; it matters for IDE-style
invocations (--include-unstaged etc.), which may run from any subdirectory.
"""
repo = git_proxy.get_repo(cwd, search_parent_directories=True)
return repo.working_tree_dir or cwd


def _validate_base_ref(repo_path: str, base_ref: str) -> None:
"""Raise a clear, user-facing error for an unresolvable `--base-ref`.

A repository with no commits at all is a valid state (the diff falls back to comparing
against the empty tree), so validation is skipped in that case.
"""
repo = git_proxy.get_repo(repo_path)

try:
repo.rev_parse(consts.GIT_HEAD_COMMIT_REV)
except Exception as e:
logger.debug('Repository has no commits yet; skipping --base-ref validation', exc_info=e)
return

try:
repo.commit(base_ref)
except Exception as e:
raise UnresolvedGitRefError(base_ref) from e


def pre_commit_command(
ctx: typer.Context,
_: Annotated[Optional[list[str]], typer.Argument(help='Ignored arguments', hidden=True)] = None,
base_ref: Annotated[
str,
typer.Option(
'--base-ref',
'-b',
help='Git ref (commit, branch, or tag) to diff against. Defaults to HEAD, matching the '
'pre-commit hook behavior. Combine with --include-unstaged for IDE-style local diff scanning.',
),
] = consts.GIT_HEAD_COMMIT_REV,
include_unstaged: Annotated[
bool,
typer.Option(
'--include-unstaged',
help='Also scan unstaged changes to tracked files, not just what is staged. '
'Off by default, so the pre-commit hook flow is unaffected.',
),
] = False,
paths: Annotated[
Optional[list[Path]],
typer.Option(
'--path',
help='Optional paths to scope the diff scan to (e.g. the file currently open in an IDE). '
'Defaults to the entire working directory. Repeatable.',
show_default=False,
resolve_path=True,
),
] = None,
) -> None:
repo_path = os.getcwd() # change locally for easy testing
try:
repo_path = _resolve_repo_root(os.getcwd())
_validate_base_ref(repo_path, base_ref)
str_paths = [str(path) for path in paths] if paths else None
# realpath both sides: repo_path (from GitPython) and str_path (from Click's
# resolve_path=True) can disagree on 8.3 short-name vs long-name form on Windows,
# which would otherwise make an in-repo path look like it's outside the repo.
repo_path_real = os.path.realpath(repo_path)
for str_path in str_paths or []:
if os.path.commonpath([repo_path_real, os.path.realpath(str_path)]) != repo_path_real:
raise ScanPathOutsideRepositoryError(str_path, repo_path)
except Exception as e:
handle_scan_exception(ctx, e)
return

progress_bar = ctx.obj['progress_bar']
progress_bar.start()

scan_pre_commit(ctx, repo_path)
try:
scan_pre_commit(ctx, repo_path, base_ref=base_ref, include_unstaged=include_unstaged, paths=str_paths)
except Exception as e:
handle_scan_exception(ctx, e)
19 changes: 19 additions & 0 deletions cycode/cli/exceptions/custom_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,25 @@ def __str__(self) -> str:
return f'Error occurred while parsing terraform plan file. Path: {self.file_path}'


class ScanPathOutsideRepositoryError(CycodeError):
def __init__(self, path: str, repo_path: str) -> None:
self.path = path
self.repo_path = repo_path
super().__init__()

def __str__(self) -> str:
return f'The path {self.path!r} is outside the repository {self.repo_path!r}'


class UnresolvedGitRefError(CycodeError):
def __init__(self, ref: str) -> None:
self.ref = ref
super().__init__()

def __str__(self) -> str:
return f'Could not resolve git ref: {self.ref!r}'


_SSL_ERROR_CA_BUNDLE_HINT = (
'set the REQUESTS_CA_BUNDLE (or CURL_CA_BUNDLE) environment variable to the path of a valid .pem or similar'
)
Expand Down
Loading
Loading