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 == ''