Skip to content

Commit aac1446

Browse files
authored
CM-54797 add SBOM import support to cli (#358)
1 parent 8e2a160 commit aac1446

File tree

14 files changed

+663
-6
lines changed

14 files changed

+663
-6
lines changed

README.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,9 @@ This guide walks you through both installation and usage.
5656
6. [Ignoring via a config file](#ignoring-via-a-config-file)
5757
6. [Report command](#report-command)
5858
1. [Generating SBOM Report](#generating-sbom-report)
59-
7. [Scan logs](#scan-logs)
60-
8. [Syntax Help](#syntax-help)
59+
7. [Import command](#import-command)
60+
8. [Scan logs](#scan-logs)
61+
9. [Syntax Help](#syntax-help)
6162

6263
# Prerequisites
6364

@@ -1295,6 +1296,26 @@ To create an SBOM report for a path:\
12951296
For example:\
12961297
`cycode report sbom --format spdx-2.3 --include-vulnerabilities --include-dev-dependencies path /path/to/local/project`
12971298
1299+
# Import Command
1300+
1301+
## Importing SBOM
1302+
1303+
A software bill of materials (SBOM) is an inventory of all constituent components and software dependencies involved in the development and delivery of an application.
1304+
Using this command, you can import an SBOM file from your file system into Cycode.
1305+
1306+
The following options are available for use with this command:
1307+
1308+
| Option | Description | Required | Default |
1309+
|----------------------------------------------------|--------------------------------------------|----------|-------------------------------------------------------|
1310+
| `-n, --name TEXT` | Display name of the SBOM | Yes | |
1311+
| `-v, --vendor TEXT` | Name of the entity that provided the SBOM | Yes | |
1312+
| `-l, --label TEXT` | Attach label to the SBOM | No | |
1313+
| `-o, --owner TEXT` | Email address of the Cycode user that serves as point of contact for this SBOM | No | |
1314+
| `-b, --business-impact [High \| Medium \| Low]` | Business Impact | No | Medium |
1315+
1316+
For example:\
1317+
`cycode import sbom --name example-sbom --vendor cycode -label tag1 -label tag2 --owner example@cycode.com /path/to/local/project`
1318+
12981319
# Scan Logs
12991320
13001321
All CLI scans are logged in Cycode. The logs can be found under Settings > CLI Logs.

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, report_import, scan, status
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: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import typer
2+
3+
from cycode.cli.apps.report_import.report_import_command import report_import_command
4+
from cycode.cli.apps.report_import.sbom import sbom_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)
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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
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)
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from pathlib import Path
2+
from typing import Annotated, Optional
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+
13+
def sbom_command(
14+
ctx: typer.Context,
15+
path: Annotated[
16+
Path,
17+
typer.Argument(
18+
exists=True, resolve_path=True, dir_okay=False, readable=True, help='Path to SBOM file.', show_default=False
19+
),
20+
],
21+
sbom_name: Annotated[
22+
str, typer.Option('--name', '-n', help='SBOM Name.', case_sensitive=False, show_default=False)
23+
],
24+
vendor: Annotated[
25+
str, typer.Option('--vendor', '-v', help='Vendor Name.', case_sensitive=False, show_default=False)
26+
],
27+
labels: Annotated[
28+
Optional[list[str]],
29+
typer.Option(
30+
'--label', '-l', help='Label, can be specified multiple times.', case_sensitive=False, show_default=False
31+
),
32+
] = None,
33+
owners: Annotated[
34+
Optional[list[str]],
35+
typer.Option(
36+
'--owner',
37+
'-o',
38+
help='Email address of a user in Cycode platform, can be specified multiple times.',
39+
case_sensitive=True,
40+
show_default=False,
41+
),
42+
] = None,
43+
business_impact: Annotated[
44+
BusinessImpactOption,
45+
typer.Option(
46+
'--business-impact',
47+
'-b',
48+
help='Business Impact.',
49+
case_sensitive=True,
50+
show_default=True,
51+
),
52+
] = BusinessImpactOption.MEDIUM,
53+
) -> None:
54+
"""Import SBOM."""
55+
add_breadcrumb('sbom')
56+
57+
client = get_import_sbom_cycode_client(ctx)
58+
59+
import_parameters = ImportSbomParameters(
60+
Name=sbom_name,
61+
Vendor=vendor,
62+
BusinessImpact=business_impact,
63+
Labels=labels,
64+
Owners=owners,
65+
)
66+
67+
try:
68+
if not path.exists():
69+
from errno import ENOENT
70+
from os import strerror
71+
72+
raise FileNotFoundError(ENOENT, strerror(ENOENT), path.absolute())
73+
74+
client.request_sbom_import_execution(import_parameters, path)
75+
except Exception as e:
76+
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,11 +3,12 @@
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

11+
from cycode.cyclient.import_sbom_client import ImportSbomClient
1112
from cycode.cyclient.report_client import ReportClient
1213
from cycode.cyclient.scan_client import ScanClient
1314

@@ -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: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import dataclasses
2+
from pathlib import Path
3+
from typing import Optional
4+
5+
from requests import Response
6+
7+
from cycode.cli.cli_types import BusinessImpactOption
8+
from cycode.cli.exceptions.custom_exceptions import RequestHttpError
9+
from cycode.cyclient import models
10+
from cycode.cyclient.cycode_client_base import CycodeClientBase
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+
with open(file_path.absolute(), 'rb') as f:
53+
request_args = {
54+
'url_path': self.IMPORT_SBOM_REQUEST_PATH,
55+
'data': form_data,
56+
'files': {'File': f},
57+
}
58+
59+
response = self.client.post(**request_args)
60+
61+
if response.status_code != 201:
62+
raise RequestHttpError(response.status_code, response.text, response)
63+
64+
def get_owners_user_ids(self, owners: list[str]) -> list[str]:
65+
return [self._get_user_id_by_email(owner) for owner in owners]
66+
67+
def _get_user_id_by_email(self, email: str) -> str:
68+
request_args = {'url_path': self.GET_USER_ID_REQUEST_PATH, 'params': {'email': email}}
69+
70+
response = self.client.get(**request_args)
71+
member_details = self.parse_requested_member_details_response(response)
72+
73+
if not member_details.items:
74+
raise Exception(
75+
f"Failed to find user with email '{email}'. Verify this email is registered to Cycode platform"
76+
)
77+
return member_details.items.pop(0).external_id
78+
79+
@staticmethod
80+
def parse_requested_member_details_response(response: Response) -> models.MemberDetails:
81+
return models.RequestedMemberDetailsResultSchema().load(response.json())

0 commit comments

Comments
 (0)