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. 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..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']] + 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 new file mode 100644 index 00000000..de218da5 --- /dev/null +++ b/tests/cli/commands/scan/test_scan_command.py @@ -0,0 +1,52 @@ +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, 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 + )