diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 9b2aa280..427f2d78 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -28,17 +28,32 @@ _SECRET_RICH_HELP_PANEL = 'Secret options' +def _single_value_callback(ctx: typer.Context, param: typer.CallbackParam, value: list) -> list: + if len(value) > 1: + values_str = ', '.join(str(v) for v in value) + param_hint = '/'.join(sorted(param.opts, key=len)) + err = typer.BadParameter( + f'Only one value can be specified per command. Got: {values_str}. Run a separate command for each value.', + ctx=ctx, + param_hint=param_hint, + ) + err.exit_code = 1 + raise err + return value + + def scan_command( ctx: typer.Context, scan_type: Annotated[ - ScanTypeOption, + list[ScanTypeOption], typer.Option( '--scan-type', '-t', help='Specify the type of scan you wish to execute.', case_sensitive=False, + callback=_single_value_callback, ), - ] = ScanTypeOption.SECRET, + ] = (ScanTypeOption.SECRET,), soft_fail: Annotated[ bool, typer.Option('--soft-fail', help='Run the scan without failing; always return a non-error status code.') ] = False, @@ -137,6 +152,9 @@ def scan_command( param_hint='--export-file', ) + # _single_value_callback validated exactly one value was provided; unwrap from list + scan_type = scan_type[0] + ctx.obj['show_secret'] = show_secret ctx.obj['soft_fail'] = soft_fail ctx.obj['stop_on_error'] = stop_on_error diff --git a/tests/cli/commands/scan/test_scan_command.py b/tests/cli/commands/scan/test_scan_command.py index de218da5..bb5f363d 100644 --- a/tests/cli/commands/scan/test_scan_command.py +++ b/tests/cli/commands/scan/test_scan_command.py @@ -1,11 +1,19 @@ +import re + import click import pytest import typer +from typer.testing import CliRunner +from cycode.cli.app import app 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 _strip_ansi(text: str) -> str: + return re.sub(r'\x1b\[[0-9;]*[mGKHF]', '', text) + + def _make_ctx(**obj_overrides: object) -> click.Context: obj = { 'soft_fail': False, @@ -25,6 +33,21 @@ def _invoke_result_callback(ctx: click.Context) -> int: return exc_info.value.exit_code +class TestScanCommand: + def test_multiple_scan_types_rejected(self) -> None: + result = CliRunner().invoke(app, ['scan', '-t', 'iac', '-t', 'sast', 'path', '.']) + assert result.exit_code == 1 + output = _strip_ansi(result.output) + assert '-t/--scan-type' in output + assert 'iac' in output + assert 'sast' in output + + def test_single_scan_type_accepted(self) -> None: + result = CliRunner().invoke(app, ['scan', '-t', 'iac', '--help']) + assert result.exit_code == 0 + assert 'Error' not in result.output + + class TestScanCommandResultCallback: def test_no_issues_no_errors_exits_zero(self) -> None: assert _invoke_result_callback(_make_ctx()) == NO_ISSUES_STATUS_CODE