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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 4 additions & 1 deletion cycode/cli/apps/scan/scan_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
1 change: 1 addition & 0 deletions cycode/cli/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions tests/cli/commands/scan/test_scan_command.py
Original file line number Diff line number Diff line change
@@ -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
)
Loading