Skip to content

Commit c4aa2fe

Browse files
committed
initial
1 parent 8e2a160 commit c4aa2fe

File tree

11 files changed

+243
-2
lines changed

11 files changed

+243
-2
lines changed

cycode/cli/app.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from typer.completion import install_callback, show_callback
1010

1111
from cycode import __version__
12-
from cycode.cli.apps import ai_remediation, auth, configure, ignore, report, scan, status
12+
from cycode.cli.apps import ai_remediation, auth, configure, ignore, report, scan, status,report_import
1313

1414
if sys.version_info >= (3, 10):
1515
from cycode.cli.apps import mcp
@@ -50,6 +50,7 @@
5050
app.add_typer(configure.app)
5151
app.add_typer(ignore.app)
5252
app.add_typer(report.app)
53+
app.add_typer(report_import.app)
5354
app.add_typer(scan.app)
5455
app.add_typer(status.app)
5556
if sys.version_info >= (3, 10):
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import typer
2+
3+
from cycode.cli.apps.report_import.sbom import sbom_command
4+
from cycode.cli.apps.report_import.report_import_command import report_import_command
5+
6+
app = typer.Typer(name='import', no_args_is_help=True)
7+
app.callback(short_help='Import report. You`ll need to specify which report type to import.')(report_import_command)
8+
app.command(name='sbom',short_help='Import SBOM report from a local path.')(sbom_command)
9+
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import typer
2+
3+
from cycode.cli.utils.sentry import add_breadcrumb
4+
5+
6+
def report_import_command(ctx: typer.Context) -> int:
7+
""":bar_chart: [bold cyan]Import security reports.[/]
8+
9+
Example usage:
10+
* `cycode import sbom`: Import SBOM report
11+
"""
12+
add_breadcrumb('import')
13+
return 1
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import typer
2+
3+
from cycode.cli.apps.report_import.sbom.sbom_command import sbom_command
4+
5+
app = typer.Typer(name='sbom')
6+
app.command(name='path',short_help='Import SBOM report from a local path.')(sbom_command)
7+
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from pathlib import Path
2+
from typing import Annotated, Optional, List
3+
4+
import typer
5+
6+
from cycode.cli.cli_types import BusinessImpactOption
7+
from cycode.cli.exceptions.handle_report_sbom_errors import handle_report_exception
8+
from cycode.cli.utils.get_api_client import get_import_sbom_cycode_client
9+
from cycode.cli.utils.sentry import add_breadcrumb
10+
from cycode.cyclient.import_sbom_client import ImportSbomParameters
11+
12+
_Input_RICH_HELP_PANEL = 'Input options'
13+
14+
15+
def sbom_command(
16+
ctx: typer.Context,
17+
input_file: Annotated[
18+
Path,
19+
typer.Option(
20+
'--file',
21+
help='Path to an SBOM file.',
22+
show_default=False,
23+
dir_okay=False,
24+
readable=True,
25+
rich_help_panel=_Input_RICH_HELP_PANEL,
26+
),
27+
],
28+
sbom_name: Annotated[
29+
str, typer.Option('--name', '-n', help='SBOM Name.', case_sensitive=False, show_default=False)
30+
],
31+
vendor: Annotated[
32+
str, typer.Option('--vendor', '-v', help='Vendor Name.', case_sensitive=False, show_default=False)
33+
],
34+
labels: Annotated[
35+
Optional[List[str]], typer.Option('--label', '-l', help='Label, can be specified multiple times.', case_sensitive=False, show_default=False)
36+
] = [],
37+
owners: Annotated[
38+
Optional[List[str]], typer.Option('--owner', '-o', help='Email address of a user in Cycode platform, can be specified multiple times.', case_sensitive=True, show_default=False)
39+
] = [],
40+
business_impact: Annotated[
41+
BusinessImpactOption,
42+
typer.Option(
43+
'--business-impact',
44+
'-b',
45+
help='Business Impact.',
46+
case_sensitive=True,
47+
show_default=True,
48+
),
49+
] = BusinessImpactOption.MEDIUM,
50+
) -> None:
51+
"""Import SBOM."""
52+
add_breadcrumb('sbom')
53+
54+
client = get_import_sbom_cycode_client(ctx)
55+
56+
import_parameters = ImportSbomParameters(
57+
Name=sbom_name,
58+
Vendor=vendor,
59+
BusinessImpact=business_impact,
60+
Labels=labels,
61+
Owners=owners,
62+
)
63+
64+
try:
65+
if not input_file.exists():
66+
from errno import ENOENT
67+
from os import strerror
68+
raise FileNotFoundError(ENOENT, strerror(ENOENT), input_file.absolute())
69+
70+
client.request_sbom_import_execution(import_parameters, input_file)
71+
except Exception as e:
72+
handle_report_exception(ctx, e)

cycode/cli/cli_types.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ class SbomOutputFormatOption(StrEnum):
5252
JSON = 'json'
5353

5454

55+
class BusinessImpactOption(StrEnum):
56+
HIGH = 'High'
57+
MEDIUM = 'Medium'
58+
LOW = 'Low'
59+
60+
5561
class SeverityOption(StrEnum):
5662
INFO = 'info'
5763
LOW = 'low'

cycode/cli/utils/get_api_client.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33
import click
44

55
from cycode.cli.user_settings.credentials_manager import CredentialsManager
6-
from cycode.cyclient.client_creator import create_report_client, create_scan_client
6+
from cycode.cyclient.client_creator import create_import_sbom_client, create_report_client, create_scan_client
77

88
if TYPE_CHECKING:
99
import typer
1010

1111
from cycode.cyclient.report_client import ReportClient
1212
from cycode.cyclient.scan_client import ScanClient
13+
from cycode.cyclient.import_sbom_client import ImportSbomClient
1314

1415

1516
def _get_cycode_client(
@@ -38,6 +39,12 @@ def get_report_cycode_client(ctx: 'typer.Context', hide_response_log: bool = Tru
3839
return _get_cycode_client(create_report_client, client_id, client_secret, hide_response_log)
3940

4041

42+
def get_import_sbom_cycode_client(ctx: 'typer.Context', hide_response_log: bool = True) -> 'ImportSbomClient':
43+
client_id = ctx.obj.get('client_id')
44+
client_secret = ctx.obj.get('client_secret')
45+
return _get_cycode_client(create_import_sbom_client, client_id, client_secret, hide_response_log)
46+
47+
4148
def _get_configured_credentials() -> tuple[str, str]:
4249
credentials_manager = CredentialsManager()
4350
return credentials_manager.get_credentials()

cycode/cyclient/client_creator.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from cycode.cyclient.config_dev import DEV_CYCODE_API_URL
33
from cycode.cyclient.cycode_dev_based_client import CycodeDevBasedClient
44
from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient
5+
from cycode.cyclient.import_sbom_client import ImportSbomClient
56
from cycode.cyclient.report_client import ReportClient
67
from cycode.cyclient.scan_client import ScanClient
78
from cycode.cyclient.scan_config_base import DefaultScanConfig, DevScanConfig
@@ -21,3 +22,8 @@ def create_scan_client(client_id: str, client_secret: str, hide_response_log: bo
2122
def create_report_client(client_id: str, client_secret: str, _: bool) -> ReportClient:
2223
client = CycodeDevBasedClient(DEV_CYCODE_API_URL) if dev_mode else CycodeTokenBasedClient(client_id, client_secret)
2324
return ReportClient(client)
25+
26+
27+
def create_import_sbom_client(client_id: str, client_secret: str, _: bool) -> ImportSbomClient:
28+
client = CycodeDevBasedClient(DEV_CYCODE_API_URL) if dev_mode else CycodeTokenBasedClient(client_id, client_secret)
29+
return ImportSbomClient(client)
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import dataclasses
2+
from typing import List, Optional
3+
from pathlib import Path
4+
from requests import Response
5+
from urllib.parse import quote
6+
7+
from cycode.cli.cli_types import BusinessImpactOption
8+
from cycode.cli.exceptions.custom_exceptions import CycodeError, RequestHttpError
9+
from cycode.cyclient.cycode_client_base import CycodeClientBase
10+
from cycode.cyclient import models
11+
12+
13+
@dataclasses.dataclass
14+
class ImportSbomParameters:
15+
Name: str
16+
Vendor: str
17+
BusinessImpact: BusinessImpactOption
18+
Labels: Optional[List[str]]
19+
Owners: Optional[List[str]]
20+
21+
def _owners_to_ids(self) -> List[str]:
22+
return []
23+
24+
def to_request_form(self) -> dict:
25+
form_data = {}
26+
for field in dataclasses.fields(self):
27+
key = field.name
28+
val = getattr(self,key)
29+
if val is None or len(val) == 0:
30+
continue
31+
if isinstance(val,list):
32+
form_data[f"{key}[]"] = val
33+
else:
34+
form_data[key] = val
35+
return form_data
36+
37+
38+
class ImportSbomClient:
39+
IMPORT_SBOM_REQUEST_PATH: str = 'v4/sbom/import'
40+
GET_USER_ID_REQUEST_PATH: str = 'v4/members'
41+
42+
def __init__(self, client: CycodeClientBase) -> None:
43+
self.client = client
44+
45+
def request_sbom_import_execution(self, params: ImportSbomParameters, file_path: Path) -> None:
46+
if params.Owners:
47+
owners_ids = self.get_owners_user_ids(params.Owners)
48+
params.Owners = owners_ids
49+
50+
form_data = params.to_request_form()
51+
52+
request_args = {
53+
'url_path': self.IMPORT_SBOM_REQUEST_PATH,
54+
'data': form_data,
55+
'files': {
56+
'File': open(file_path.absolute(),'rb')
57+
},
58+
}
59+
60+
response = self.client.post(**request_args)
61+
62+
if response.status_code != 201:
63+
raise RequestHttpError(response.status_code, response.text,response)
64+
65+
def get_owners_user_ids(self,owners: List[str]) -> List[str]:
66+
return [ self._get_user_id_by_email(owner) for owner in owners]
67+
68+
def _get_user_id_by_email(self,email: str) -> str:
69+
70+
request_args = {
71+
'url_path': self.GET_USER_ID_REQUEST_PATH,
72+
'params': {
73+
'email': email
74+
}
75+
}
76+
77+
response = self.client.get(**request_args)
78+
member_details = self.parse_requested_member_details_response(response)
79+
80+
if not member_details.items:
81+
raise Exception(f"Failed to find user with email '{email}'. Verify this email is registered to Cycode platform")
82+
return member_details.items.pop(0).external_id
83+
84+
@staticmethod
85+
def parse_requested_member_details_response(response: Response) -> models.MemberDetails:
86+
return models.RequestedMemberDetailsResultSchema().load(response.json())
87+
88+
89+

cycode/cyclient/models.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,37 @@ class Meta:
401401
def build_dto(self, data: dict[str, Any], **_) -> SbomReport:
402402
return SbomReport(**data)
403403

404+
@dataclass
405+
class Member:
406+
external_id: str
407+
408+
class MemberSchema(Schema):
409+
class Meta:
410+
unknown = EXCLUDE
411+
412+
external_id = fields.String()
413+
414+
@post_load
415+
def build_dto(self, data: dict[str, Any], **_) -> Member:
416+
return Member(**data)
417+
418+
@dataclass
419+
class MemberDetails:
420+
items: list[Member]
421+
page_size: int
422+
next_page_token: Optional[str]
423+
424+
class RequestedMemberDetailsResultSchema(Schema):
425+
class Meta:
426+
unknown = EXCLUDE
427+
428+
items = fields.List(fields.Nested(MemberSchema))
429+
page_size = fields.Integer()
430+
next_page_token = fields.String(allow_none=True)
431+
432+
@post_load
433+
def build_dto(self, data: dict[str,Any], **_) -> MemberDetails:
434+
return MemberDetails(**data)
404435

405436
@dataclass
406437
class ClassificationData:

0 commit comments

Comments
 (0)