Skip to content

Commit d292487

Browse files
authored
CM-42034 - Add internal AI remediations command for IDE plugins (#270)
1 parent ebb8634 commit d292487

17 files changed

+301
-74
lines changed

.github/workflows/build_executable.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
strategy:
1616
fail-fast: false
1717
matrix:
18-
os: [ ubuntu-20.04, macos-12, macos-14, windows-2019 ]
18+
os: [ ubuntu-20.04, macos-13, macos-14, windows-2019 ]
1919
mode: [ 'onefile', 'onedir' ]
2020
exclude:
2121
- os: ubuntu-20.04

cycode/cli/commands/ai_remediation/__init__.py

Whitespace-only changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import os
2+
3+
import click
4+
from patch_ng import fromstring
5+
from rich.console import Console
6+
from rich.markdown import Markdown
7+
8+
from cycode.cli.exceptions.handle_ai_remediation_errors import handle_ai_remediation_exception
9+
from cycode.cli.models import CliResult
10+
from cycode.cli.printers import ConsolePrinter
11+
from cycode.cli.utils.get_api_client import get_scan_cycode_client
12+
13+
14+
def _echo_remediation(context: click.Context, remediation_markdown: str, is_fix_available: bool) -> None:
15+
printer = ConsolePrinter(context)
16+
if printer.is_json_printer:
17+
data = {'remediation': remediation_markdown, 'is_fix_available': is_fix_available}
18+
printer.print_result(CliResult(success=True, message='Remediation fetched successfully', data=data))
19+
else: # text or table
20+
Console().print(Markdown(remediation_markdown))
21+
22+
23+
def _apply_fix(context: click.Context, diff: str, is_fix_available: bool) -> None:
24+
printer = ConsolePrinter(context)
25+
if not is_fix_available:
26+
printer.print_result(CliResult(success=False, message='Fix is not available for this violation'))
27+
return
28+
29+
patch = fromstring(diff.encode('UTF-8'))
30+
if patch is False:
31+
printer.print_result(CliResult(success=False, message='Failed to parse fix diff'))
32+
return
33+
34+
is_fix_applied = patch.apply(root=os.getcwd(), strip=0)
35+
if is_fix_applied:
36+
printer.print_result(CliResult(success=True, message='Fix applied successfully'))
37+
else:
38+
printer.print_result(CliResult(success=False, message='Failed to apply fix'))
39+
40+
41+
@click.command(short_help='Get AI remediation (INTERNAL).', hidden=True)
42+
@click.argument('detection_id', nargs=1, type=click.UUID, required=True)
43+
@click.option(
44+
'--fix',
45+
is_flag=True,
46+
default=False,
47+
help='Apply fixes to resolve violations. Fix is not available for all violations.',
48+
type=click.BOOL,
49+
required=False,
50+
)
51+
@click.pass_context
52+
def ai_remediation_command(context: click.Context, detection_id: str, fix: bool) -> None:
53+
client = get_scan_cycode_client()
54+
55+
try:
56+
remediation_markdown = client.get_ai_remediation(detection_id)
57+
fix_diff = client.get_ai_remediation(detection_id, fix=True)
58+
is_fix_available = bool(fix_diff) # exclude empty string, None, etc.
59+
60+
if fix:
61+
_apply_fix(context, fix_diff, is_fix_available)
62+
else:
63+
_echo_remediation(context, remediation_markdown, is_fix_available)
64+
except Exception as err:
65+
handle_ai_remediation_exception(context, err)
66+
67+
context.exit()

cycode/cli/commands/main_cli.py

+2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
import click
55

6+
from cycode.cli.commands.ai_remediation.ai_remediation_command import ai_remediation_command
67
from cycode.cli.commands.auth.auth_command import auth_command
78
from cycode.cli.commands.configure.configure_command import configure_command
89
from cycode.cli.commands.ignore.ignore_command import ignore_command
@@ -30,6 +31,7 @@
3031
'auth': auth_command,
3132
'version': version_command,
3233
'status': status_command,
34+
'ai_remediation': ai_remediation_command,
3335
},
3436
context_settings=CLI_CONTEXT_SETTINGS,
3537
)

cycode/cli/commands/version/version_command.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from cycode.cli.consts import PROGRAM_NAME
77

88

9-
@click.command(short_help='Show the CLI version and exit.')
9+
@click.command(short_help='Show the CLI version and exit. Use `cycode status` instead.', deprecated=True)
1010
@click.pass_context
1111
def version_command(context: click.Context) -> None:
1212
output = context.obj['output']

cycode/cli/consts.py

+4
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,10 @@
159159
SYNC_SCAN_TIMEOUT_IN_SECONDS_ENV_VAR_NAME = 'SYNC_SCAN_TIMEOUT_IN_SECONDS'
160160
DEFAULT_SYNC_SCAN_TIMEOUT_IN_SECONDS = 180
161161

162+
# ai remediation
163+
AI_REMEDIATION_TIMEOUT_IN_SECONDS_ENV_VAR_NAME = 'AI_REMEDIATION_TIMEOUT_IN_SECONDS'
164+
DEFAULT_AI_REMEDIATION_TIMEOUT_IN_SECONDS = 60
165+
162166
# report with polling
163167
REPORT_POLLING_WAIT_INTERVAL_IN_SECONDS = 5
164168
DEFAULT_REPORT_POLLING_TIMEOUT_IN_SECONDS = 600

cycode/cli/exceptions/common.py

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from typing import Optional
2+
3+
import click
4+
5+
from cycode.cli.models import CliError, CliErrors
6+
from cycode.cli.printers import ConsolePrinter
7+
from cycode.cli.sentry import capture_exception
8+
9+
10+
def handle_errors(
11+
context: click.Context, err: BaseException, cli_errors: CliErrors, *, return_exception: bool = False
12+
) -> Optional['CliError']:
13+
ConsolePrinter(context).print_exception(err)
14+
15+
if type(err) in cli_errors:
16+
error = cli_errors[type(err)]
17+
18+
if error.soft_fail is True:
19+
context.obj['soft_fail'] = True
20+
21+
if return_exception:
22+
return error
23+
24+
ConsolePrinter(context).print_error(error)
25+
return None
26+
27+
if isinstance(err, click.ClickException):
28+
raise err
29+
30+
capture_exception(err)
31+
32+
unknown_error = CliError(code='unknown_error', message=str(err))
33+
if return_exception:
34+
return unknown_error
35+
36+
ConsolePrinter(context).print_error(unknown_error)
37+
exit(1)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import click
2+
3+
from cycode.cli.exceptions.common import handle_errors
4+
from cycode.cli.exceptions.custom_exceptions import KNOWN_USER_FRIENDLY_REQUEST_ERRORS, RequestHttpError
5+
from cycode.cli.models import CliError, CliErrors
6+
7+
8+
class AiRemediationNotFoundError(Exception): ...
9+
10+
11+
def handle_ai_remediation_exception(context: click.Context, err: Exception) -> None:
12+
if isinstance(err, RequestHttpError) and err.status_code == 404:
13+
err = AiRemediationNotFoundError()
14+
15+
errors: CliErrors = {
16+
**KNOWN_USER_FRIENDLY_REQUEST_ERRORS,
17+
AiRemediationNotFoundError: CliError(
18+
code='ai_remediation_not_found',
19+
message='The AI remediation was not found. Please try different detection ID',
20+
),
21+
}
22+
handle_errors(context, err, errors)
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,12 @@
1-
from typing import Optional
2-
31
import click
42

53
from cycode.cli.exceptions import custom_exceptions
4+
from cycode.cli.exceptions.common import handle_errors
65
from cycode.cli.exceptions.custom_exceptions import KNOWN_USER_FRIENDLY_REQUEST_ERRORS
76
from cycode.cli.models import CliError, CliErrors
8-
from cycode.cli.printers import ConsolePrinter
9-
from cycode.cli.sentry import capture_exception
10-
117

12-
def handle_report_exception(context: click.Context, err: Exception) -> Optional[CliError]:
13-
ConsolePrinter(context).print_exception()
148

9+
def handle_report_exception(context: click.Context, err: Exception) -> None:
1510
errors: CliErrors = {
1611
**KNOWN_USER_FRIENDLY_REQUEST_ERRORS,
1712
custom_exceptions.ScanAsyncError: CliError(
@@ -25,16 +20,4 @@ def handle_report_exception(context: click.Context, err: Exception) -> Optional[
2520
'Please try again by executing the `cycode report` command',
2621
),
2722
}
28-
29-
if type(err) in errors:
30-
error = errors[type(err)]
31-
32-
ConsolePrinter(context).print_error(error)
33-
return None
34-
35-
if isinstance(err, click.ClickException):
36-
raise err
37-
38-
capture_exception(err)
39-
40-
raise click.ClickException(str(err))
23+
handle_errors(context, err, errors)

cycode/cli/exceptions/handle_scan_errors.py

+4-29
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,17 @@
33
import click
44

55
from cycode.cli.exceptions import custom_exceptions
6+
from cycode.cli.exceptions.common import handle_errors
67
from cycode.cli.exceptions.custom_exceptions import KNOWN_USER_FRIENDLY_REQUEST_ERRORS
78
from cycode.cli.models import CliError, CliErrors
8-
from cycode.cli.printers import ConsolePrinter
9-
from cycode.cli.sentry import capture_exception
109
from cycode.cli.utils.git_proxy import git_proxy
1110

1211

1312
def handle_scan_exception(
14-
context: click.Context, e: Exception, *, return_exception: bool = False
13+
context: click.Context, err: Exception, *, return_exception: bool = False
1514
) -> Optional[CliError]:
1615
context.obj['did_fail'] = True
1716

18-
ConsolePrinter(context).print_exception(e)
19-
2017
errors: CliErrors = {
2118
**KNOWN_USER_FRIENDLY_REQUEST_ERRORS,
2219
custom_exceptions.ScanAsyncError: CliError(
@@ -35,7 +32,7 @@ def handle_scan_exception(
3532
custom_exceptions.TfplanKeyError: CliError(
3633
soft_fail=True,
3734
code='key_error',
38-
message=f'\n{e!s}\n'
35+
message=f'\n{err!s}\n'
3936
'A crucial field is missing in your terraform plan file. '
4037
'Please make sure that your file is well formed '
4138
'and execute the scan again',
@@ -48,26 +45,4 @@ def handle_scan_exception(
4845
),
4946
}
5047

51-
if type(e) in errors:
52-
error = errors[type(e)]
53-
54-
if error.soft_fail is True:
55-
context.obj['soft_fail'] = True
56-
57-
if return_exception:
58-
return error
59-
60-
ConsolePrinter(context).print_error(error)
61-
return None
62-
63-
if isinstance(e, click.ClickException):
64-
raise e
65-
66-
capture_exception(e)
67-
68-
unknown_error = CliError(code='unknown_error', message=str(e))
69-
if return_exception:
70-
return unknown_error
71-
72-
ConsolePrinter(context).print_error(unknown_error)
73-
exit(1)
48+
return handle_errors(context, err, errors, return_exception=return_exception)

cycode/cli/models.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ class CliError(NamedTuple):
6363
soft_fail: bool = False
6464

6565

66-
CliErrors = Dict[Type[Exception], CliError]
66+
CliErrors = Dict[Type[BaseException], CliError]
6767

6868

6969
class CliResult(NamedTuple):

cycode/cli/printers/console_printer.py

+12
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,15 @@ def print_exception(self, e: Optional[BaseException] = None, force_print: bool =
6060
"""Print traceback message in stderr if verbose mode is set."""
6161
if force_print or self.context.obj.get('verbose', False):
6262
self._printer_class(self.context).print_exception(e)
63+
64+
@property
65+
def is_json_printer(self) -> bool:
66+
return self._printer_class == JsonPrinter
67+
68+
@property
69+
def is_table_printer(self) -> bool:
70+
return self._printer_class == TablePrinter
71+
72+
@property
73+
def is_text_printer(self) -> bool:
74+
return self._printer_class == TextPrinter

cycode/cli/user_settings/configuration_manager.py

+7
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,13 @@ def get_sync_scan_timeout_in_seconds(self) -> int:
113113
)
114114
)
115115

116+
def get_ai_remediation_timeout_in_seconds(self) -> int:
117+
return int(
118+
self._get_value_from_environment_variables(
119+
consts.AI_REMEDIATION_TIMEOUT_IN_SECONDS_ENV_VAR_NAME, consts.DEFAULT_AI_REMEDIATION_TIMEOUT_IN_SECONDS
120+
)
121+
)
122+
116123
def get_report_polling_timeout_in_seconds(self) -> int:
117124
return int(
118125
self._get_value_from_environment_variables(

cycode/cyclient/models.py

+3
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ def __init__(
1313
detection_details: dict,
1414
detection_rule_id: str,
1515
severity: Optional[str] = None,
16+
id: Optional[str] = None,
1617
) -> None:
1718
super().__init__()
19+
self.id = id
1820
self.message = message
1921
self.type = type
2022
self.severity = severity
@@ -36,6 +38,7 @@ class DetectionSchema(Schema):
3638
class Meta:
3739
unknown = EXCLUDE
3840

41+
id = fields.String(missing=None)
3942
message = fields.String()
4043
type = fields.String()
4144
severity = fields.String(missing=None)

cycode/cyclient/scan_client.py

+22
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,28 @@ def get_supported_modules_preferences(self) -> models.SupportedModulesPreference
206206
response = self.scan_cycode_client.get(url_path='preferences/api/v1/supportedmodules')
207207
return models.SupportedModulesPreferencesSchema().load(response.json())
208208

209+
@staticmethod
210+
def get_ai_remediation_path(detection_id: str) -> str:
211+
return f'scm-remediator/api/v1/ContentRemediation/preview/{detection_id}'
212+
213+
def get_ai_remediation(self, detection_id: str, *, fix: bool = False) -> str:
214+
path = self.get_ai_remediation_path(detection_id)
215+
216+
data = {
217+
'resolving_parameters': {
218+
'get_diff': True,
219+
'use_code_snippet': True,
220+
'add_diff_header': True,
221+
}
222+
}
223+
if not fix:
224+
data['resolving_parameters']['remediation_action'] = 'ReplyWithRemediationDetails'
225+
226+
response = self.scan_cycode_client.get(
227+
url_path=path, json=data, timeout=configuration_manager.get_ai_remediation_timeout_in_seconds()
228+
)
229+
return response.text.strip()
230+
209231
@staticmethod
210232
def _get_policy_type_by_scan_type(scan_type: str) -> str:
211233
scan_type_to_policy_type = {

0 commit comments

Comments
 (0)