From 5d9d0b3150b0156533ab8bf42769c60889f7ed56 Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Mon, 27 Apr 2026 09:25:50 +0300 Subject: [PATCH 1/4] CM-63288 add different status code to stop-on-error --- cycode/cli/apps/scan/scan_command.py | 5 ++++- cycode/cli/consts.py | 1 + .../files_collector/sca/nuget/restore_nuget_dependencies.py | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 62697357..9b2aa280 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -17,6 +17,7 @@ from cycode.cli.consts import ( ISSUE_DETECTED_STATUS_CODE, NO_ISSUES_STATUS_CODE, + SCAN_ERROR_STATUS_CODE, ) from cycode.cli.files_collector.file_excluder import excluder from cycode.cli.utils import scan_utils @@ -187,7 +188,9 @@ def scan_command_result_callback(ctx: click.Context, *_, **__) -> None: raise typer.Exit(0) exit_code = NO_ISSUES_STATUS_CODE - if scan_utils.is_scan_failed(ctx): + if ctx.obj.get('did_fail') and ctx.obj.get('stop_on_error'): + exit_code = SCAN_ERROR_STATUS_CODE + elif scan_utils.is_scan_failed(ctx): exit_code = ISSUE_DETECTED_STATUS_CODE raise typer.Exit(exit_code) diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 31ab6ef9..108aa0e1 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -277,6 +277,7 @@ ISSUE_DETECTED_STATUS_CODE = 1 NO_ISSUES_STATUS_CODE = 0 +SCAN_ERROR_STATUS_CODE = 2 LICENSE_COMPLIANCE_POLICY_ID = '8f681450-49e1-4f7e-85b7-0c8fe84b3a35' PACKAGE_VULNERABILITY_POLICY_ID = '9369d10a-9ac0-48d3-9921-5de7fe9a37a7' diff --git a/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py b/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py index 95ced0ff..d1d03e8d 100644 --- a/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py +++ b/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py @@ -15,7 +15,7 @@ def is_project(self, document: Document) -> bool: return any(document.path.endswith(ext) for ext in NUGET_PROJECT_FILE_EXTENSIONS) def get_commands(self, manifest_file_path: str) -> list[list[str]]: - return [['dotnet', 'restore', manifest_file_path, '--use-lock-file', '--verbosity', 'quiet']] + return [['dotnet', 'restore', manifest_file_path, '--use-lock-file', '--verbosity', 'quiet', '--ignore-failed-sources']] def get_lock_file_name(self) -> str: return NUGET_LOCK_FILE_NAME From f2180c0a0b1d39cea7f1869490f72afcb3f5a87c Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Mon, 27 Apr 2026 09:35:38 +0300 Subject: [PATCH 2/4] CM-63288 updated readme file --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 806390d0..2604bb0f 100644 --- a/README.md +++ b/README.md @@ -802,6 +802,14 @@ cycode scan -t sca --stop-on-error path ~/home/git/codebase This is useful in CI pipelines where a silent failure would produce an incomplete scan result. When `--stop-on-error` is triggered you can either fix the underlying issue or, for SCA restore failures specifically, add `--no-restore` to skip lockfile generation and scan direct dependencies only. +When `--stop-on-error` is used, the CLI distinguishes between scan errors and policy violations via exit codes: + +| Exit code | Meaning | +|-----------|---------| +| `0` | Scan completed with no violations | +| `1` | Scan completed and violations were found | +| `2` | Scan aborted due to an error (only when `--stop-on-error` is set) | + ### Repository Scan A repository scan examines an entire local repository for any exposed secrets or insecure misconfigurations. This more holistic scan type looks at everything: the current state of your repository and its commit history. It will look not only for secrets that are currently exposed within the repository but previously deleted secrets as well. From ef2af5c48c26b6a44d626644b9881f97e688c317 Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Mon, 27 Apr 2026 09:49:29 +0300 Subject: [PATCH 3/4] CM-63288 added tests to cover the expected outcomes --- tests/cli/commands/scan/test_scan_command.py | 50 ++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 tests/cli/commands/scan/test_scan_command.py diff --git a/tests/cli/commands/scan/test_scan_command.py b/tests/cli/commands/scan/test_scan_command.py new file mode 100644 index 00000000..cfb842f0 --- /dev/null +++ b/tests/cli/commands/scan/test_scan_command.py @@ -0,0 +1,50 @@ +import click +import pytest +import typer + +from cycode.cli.apps.scan.scan_command import scan_command_result_callback +from cycode.cli.consts import ISSUE_DETECTED_STATUS_CODE, NO_ISSUES_STATUS_CODE, SCAN_ERROR_STATUS_CODE + + +def _make_ctx(**obj_overrides: object) -> click.Context: + obj = { + 'soft_fail': False, + 'did_fail': False, + 'issue_detected': False, + 'stop_on_error': False, + } + obj.update(obj_overrides) + ctx = click.Context(click.Command('scan')) + ctx.obj = obj + return ctx + + +def _invoke_result_callback(ctx: click.Context) -> int: + with pytest.raises(typer.Exit) as exc_info: + with ctx: + scan_command_result_callback() + return exc_info.value.exit_code + + +class TestScanCommandResultCallback: + def test_no_issues_no_errors_exits_zero(self) -> None: + assert _invoke_result_callback(_make_ctx()) == NO_ISSUES_STATUS_CODE + + def test_issue_detected_exits_one(self) -> None: + assert _invoke_result_callback(_make_ctx(issue_detected=True)) == ISSUE_DETECTED_STATUS_CODE + + def test_did_fail_without_stop_on_error_exits_one(self) -> None: + assert _invoke_result_callback(_make_ctx(did_fail=True)) == ISSUE_DETECTED_STATUS_CODE + + def test_did_fail_with_stop_on_error_exits_two(self) -> None: + assert _invoke_result_callback(_make_ctx(did_fail=True, stop_on_error=True)) == SCAN_ERROR_STATUS_CODE + + def test_issue_detected_with_stop_on_error_exits_one(self) -> None: + # stop_on_error only affects the error code path, not violations + assert _invoke_result_callback(_make_ctx(issue_detected=True, stop_on_error=True)) == ISSUE_DETECTED_STATUS_CODE + + def test_soft_fail_overrides_violations(self) -> None: + assert _invoke_result_callback(_make_ctx(soft_fail=True, issue_detected=True)) == NO_ISSUES_STATUS_CODE + + def test_soft_fail_overrides_stop_on_error(self) -> None: + assert _invoke_result_callback(_make_ctx(soft_fail=True, did_fail=True, stop_on_error=True)) == NO_ISSUES_STATUS_CODE From e276d9544619f26737b4157b5a77fb78fd20b9ef Mon Sep 17 00:00:00 2001 From: "omer.roth" Date: Mon, 27 Apr 2026 09:51:48 +0300 Subject: [PATCH 4/4] CM-63288 ruff and lint --- .../sca/nuget/restore_nuget_dependencies.py | 12 +++++++++++- tests/cli/commands/scan/test_scan_command.py | 10 ++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py b/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py index d1d03e8d..9bd01cd0 100644 --- a/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py +++ b/cycode/cli/files_collector/sca/nuget/restore_nuget_dependencies.py @@ -15,7 +15,17 @@ def is_project(self, document: Document) -> bool: return any(document.path.endswith(ext) for ext in NUGET_PROJECT_FILE_EXTENSIONS) def get_commands(self, manifest_file_path: str) -> list[list[str]]: - return [['dotnet', 'restore', manifest_file_path, '--use-lock-file', '--verbosity', 'quiet', '--ignore-failed-sources']] + return [ + [ + 'dotnet', + 'restore', + manifest_file_path, + '--use-lock-file', + '--verbosity', + 'quiet', + '--ignore-failed-sources', + ] + ] def get_lock_file_name(self) -> str: return NUGET_LOCK_FILE_NAME diff --git a/tests/cli/commands/scan/test_scan_command.py b/tests/cli/commands/scan/test_scan_command.py index cfb842f0..de218da5 100644 --- a/tests/cli/commands/scan/test_scan_command.py +++ b/tests/cli/commands/scan/test_scan_command.py @@ -20,9 +20,8 @@ def _make_ctx(**obj_overrides: object) -> click.Context: def _invoke_result_callback(ctx: click.Context) -> int: - with pytest.raises(typer.Exit) as exc_info: - with ctx: - scan_command_result_callback() + with pytest.raises(typer.Exit) as exc_info, ctx: + scan_command_result_callback() return exc_info.value.exit_code @@ -47,4 +46,7 @@ def test_soft_fail_overrides_violations(self) -> None: assert _invoke_result_callback(_make_ctx(soft_fail=True, issue_detected=True)) == NO_ISSUES_STATUS_CODE def test_soft_fail_overrides_stop_on_error(self) -> None: - assert _invoke_result_callback(_make_ctx(soft_fail=True, did_fail=True, stop_on_error=True)) == NO_ISSUES_STATUS_CODE + assert ( + _invoke_result_callback(_make_ctx(soft_fail=True, did_fail=True, stop_on_error=True)) + == NO_ISSUES_STATUS_CODE + )