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
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,9 @@ This guide walks you through both installation and usage.
6. [Ignoring via a config file](#ignoring-via-a-config-file)
6. [Report command](#report-command)
1. [Generating SBOM Report](#generating-sbom-report)
7. [Scan logs](#scan-logs)
8. [Syntax Help](#syntax-help)
7. [Import command](#import-command)
8. [Scan logs](#scan-logs)
9. [Syntax Help](#syntax-help)

# Prerequisites

Expand Down Expand Up @@ -1295,6 +1296,26 @@ To create an SBOM report for a path:\
For example:\
`cycode report sbom --format spdx-2.3 --include-vulnerabilities --include-dev-dependencies path /path/to/local/project`

# Import Command

## Importing SBOM

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.
Using this command, you can import an SBOM file from your file system into Cycode.

The following options are available for use with this command:

| Option | Description | Required | Default |
|----------------------------------------------------|--------------------------------------------|----------|-------------------------------------------------------|
| `-n, --name TEXT` | Display name of the SBOM | Yes | |
| `-v, --vendor TEXT` | Name of the entity that provided the SBOM | Yes | |
| `-l, --label TEXT` | Attach label to the SBOM | No | |
| `-o, --owner TEXT` | Email address of the Cycode user that serves as point of contact for this SBOM | No | |
| `-b, --business-impact [High \| Medium \| Low]` | Business Impact | No | Medium |

For example:\
`cycode import sbom --name example-sbom --vendor cycode -label tag1 -label tag2 --owner [email protected] /path/to/local/project`

# Scan Logs

All CLI scans are logged in Cycode. The logs can be found under Settings > CLI Logs.
Expand Down
3 changes: 2 additions & 1 deletion cycode/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from typer.completion import install_callback, show_callback

from cycode import __version__
from cycode.cli.apps import ai_remediation, auth, configure, ignore, report, scan, status
from cycode.cli.apps import ai_remediation, auth, configure, ignore, report, report_import, scan, status

if sys.version_info >= (3, 10):
from cycode.cli.apps import mcp
Expand Down Expand Up @@ -50,6 +50,7 @@
app.add_typer(configure.app)
app.add_typer(ignore.app)
app.add_typer(report.app)
app.add_typer(report_import.app)
app.add_typer(scan.app)
app.add_typer(status.app)
if sys.version_info >= (3, 10):
Expand Down
8 changes: 8 additions & 0 deletions cycode/cli/apps/report_import/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import typer

from cycode.cli.apps.report_import.report_import_command import report_import_command
from cycode.cli.apps.report_import.sbom import sbom_command

app = typer.Typer(name='import', no_args_is_help=True)
app.callback(short_help='Import report. You`ll need to specify which report type to import.')(report_import_command)
app.command(name='sbom', short_help='Import SBOM report from a local path.')(sbom_command)
13 changes: 13 additions & 0 deletions cycode/cli/apps/report_import/report_import_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import typer

from cycode.cli.utils.sentry import add_breadcrumb


def report_import_command(ctx: typer.Context) -> int:
""":bar_chart: [bold cyan]Import security reports.[/]

Example usage:
* `cycode import sbom`: Import SBOM report
"""
add_breadcrumb('import')
return 1
6 changes: 6 additions & 0 deletions cycode/cli/apps/report_import/sbom/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import typer

from cycode.cli.apps.report_import.sbom.sbom_command import sbom_command

app = typer.Typer(name='sbom')
app.command(name='path', short_help='Import SBOM report from a local path.')(sbom_command)
76 changes: 76 additions & 0 deletions cycode/cli/apps/report_import/sbom/sbom_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from pathlib import Path
from typing import Annotated, Optional

import typer

from cycode.cli.cli_types import BusinessImpactOption
from cycode.cli.exceptions.handle_report_sbom_errors import handle_report_exception
from cycode.cli.utils.get_api_client import get_import_sbom_cycode_client
from cycode.cli.utils.sentry import add_breadcrumb
from cycode.cyclient.import_sbom_client import ImportSbomParameters


def sbom_command(
ctx: typer.Context,
path: Annotated[
Path,
typer.Argument(
exists=True, resolve_path=True, dir_okay=False, readable=True, help='Path to SBOM file.', show_default=False
),
],
sbom_name: Annotated[
str, typer.Option('--name', '-n', help='SBOM Name.', case_sensitive=False, show_default=False)
],
vendor: Annotated[
str, typer.Option('--vendor', '-v', help='Vendor Name.', case_sensitive=False, show_default=False)
],
labels: Annotated[
Optional[list[str]],
typer.Option(
'--label', '-l', help='Label, can be specified multiple times.', case_sensitive=False, show_default=False
),
] = None,
owners: Annotated[
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,
),
] = None,
business_impact: Annotated[
BusinessImpactOption,
typer.Option(
'--business-impact',
'-b',
help='Business Impact.',
case_sensitive=True,
show_default=True,
),
] = BusinessImpactOption.MEDIUM,
) -> None:
"""Import SBOM."""
add_breadcrumb('sbom')

client = get_import_sbom_cycode_client(ctx)

import_parameters = ImportSbomParameters(
Name=sbom_name,
Vendor=vendor,
BusinessImpact=business_impact,
Labels=labels,
Owners=owners,
)

try:
if not path.exists():
from errno import ENOENT
from os import strerror

raise FileNotFoundError(ENOENT, strerror(ENOENT), path.absolute())

client.request_sbom_import_execution(import_parameters, path)
except Exception as e:
handle_report_exception(ctx, e)
6 changes: 6 additions & 0 deletions cycode/cli/cli_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ class SbomOutputFormatOption(StrEnum):
JSON = 'json'


class BusinessImpactOption(StrEnum):
HIGH = 'High'
MEDIUM = 'Medium'
LOW = 'Low'


class SeverityOption(StrEnum):
INFO = 'info'
LOW = 'low'
Expand Down
9 changes: 8 additions & 1 deletion cycode/cli/utils/get_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
import click

from cycode.cli.user_settings.credentials_manager import CredentialsManager
from cycode.cyclient.client_creator import create_report_client, create_scan_client
from cycode.cyclient.client_creator import create_import_sbom_client, create_report_client, create_scan_client

if TYPE_CHECKING:
import typer

from cycode.cyclient.import_sbom_client import ImportSbomClient
from cycode.cyclient.report_client import ReportClient
from cycode.cyclient.scan_client import ScanClient

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


def get_import_sbom_cycode_client(ctx: 'typer.Context', hide_response_log: bool = True) -> 'ImportSbomClient':
client_id = ctx.obj.get('client_id')
client_secret = ctx.obj.get('client_secret')
return _get_cycode_client(create_import_sbom_client, client_id, client_secret, hide_response_log)


def _get_configured_credentials() -> tuple[str, str]:
credentials_manager = CredentialsManager()
return credentials_manager.get_credentials()
6 changes: 6 additions & 0 deletions cycode/cyclient/client_creator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from cycode.cyclient.config_dev import DEV_CYCODE_API_URL
from cycode.cyclient.cycode_dev_based_client import CycodeDevBasedClient
from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient
from cycode.cyclient.import_sbom_client import ImportSbomClient
from cycode.cyclient.report_client import ReportClient
from cycode.cyclient.scan_client import ScanClient
from cycode.cyclient.scan_config_base import DefaultScanConfig, DevScanConfig
Expand All @@ -21,3 +22,8 @@ def create_scan_client(client_id: str, client_secret: str, hide_response_log: bo
def create_report_client(client_id: str, client_secret: str, _: bool) -> ReportClient:
client = CycodeDevBasedClient(DEV_CYCODE_API_URL) if dev_mode else CycodeTokenBasedClient(client_id, client_secret)
return ReportClient(client)


def create_import_sbom_client(client_id: str, client_secret: str, _: bool) -> ImportSbomClient:
client = CycodeDevBasedClient(DEV_CYCODE_API_URL) if dev_mode else CycodeTokenBasedClient(client_id, client_secret)
return ImportSbomClient(client)
81 changes: 81 additions & 0 deletions cycode/cyclient/import_sbom_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import dataclasses
from pathlib import Path
from typing import Optional

from requests import Response

from cycode.cli.cli_types import BusinessImpactOption
from cycode.cli.exceptions.custom_exceptions import RequestHttpError
from cycode.cyclient import models
from cycode.cyclient.cycode_client_base import CycodeClientBase


@dataclasses.dataclass
class ImportSbomParameters:
Name: str
Vendor: str
BusinessImpact: BusinessImpactOption
Labels: Optional[list[str]]
Owners: Optional[list[str]]

def _owners_to_ids(self) -> list[str]:
return []

def to_request_form(self) -> dict:
form_data = {}
for field in dataclasses.fields(self):
key = field.name
val = getattr(self, key)
if val is None or len(val) == 0:
continue
if isinstance(val, list):
form_data[f'{key}[]'] = val
else:
form_data[key] = val
return form_data


class ImportSbomClient:
IMPORT_SBOM_REQUEST_PATH: str = 'v4/sbom/import'
GET_USER_ID_REQUEST_PATH: str = 'v4/members'

def __init__(self, client: CycodeClientBase) -> None:
self.client = client

def request_sbom_import_execution(self, params: ImportSbomParameters, file_path: Path) -> None:
if params.Owners:
owners_ids = self.get_owners_user_ids(params.Owners)
params.Owners = owners_ids

form_data = params.to_request_form()

with open(file_path.absolute(), 'rb') as f:
request_args = {
'url_path': self.IMPORT_SBOM_REQUEST_PATH,
'data': form_data,
'files': {'File': f},
}

response = self.client.post(**request_args)

if response.status_code != 201:
raise RequestHttpError(response.status_code, response.text, response)

def get_owners_user_ids(self, owners: list[str]) -> list[str]:
return [self._get_user_id_by_email(owner) for owner in owners]

def _get_user_id_by_email(self, email: str) -> str:
request_args = {'url_path': self.GET_USER_ID_REQUEST_PATH, 'params': {'email': email}}

response = self.client.get(**request_args)
member_details = self.parse_requested_member_details_response(response)

if not member_details.items:
raise Exception(
f"Failed to find user with email '{email}'. Verify this email is registered to Cycode platform"
)
return member_details.items.pop(0).external_id

@staticmethod
def parse_requested_member_details_response(response: Response) -> models.MemberDetails:
return models.RequestedMemberDetailsResultSchema().load(response.json())
40 changes: 38 additions & 2 deletions cycode/cyclient/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@ class DetectionSchema(Schema):
class Meta:
unknown = EXCLUDE

id = fields.String(missing=None)
id = fields.String(load_default=None)
message = fields.String()
type = fields.String()
severity = fields.String(missing=None)
severity = fields.String(load_default=None)
detection_type_id = fields.String()
detection_details = fields.Dict()
detection_rule_id = fields.String()
Expand Down Expand Up @@ -402,6 +402,42 @@ def build_dto(self, data: dict[str, Any], **_) -> SbomReport:
return SbomReport(**data)


@dataclass
class Member:
external_id: str


class MemberSchema(Schema):
class Meta:
unknown = EXCLUDE

external_id = fields.String()

@post_load
def build_dto(self, data: dict[str, Any], **_) -> Member:
return Member(**data)


@dataclass
class MemberDetails:
items: list[Member]
page_size: int
next_page_token: Optional[str]


class RequestedMemberDetailsResultSchema(Schema):
class Meta:
unknown = EXCLUDE

items = fields.List(fields.Nested(MemberSchema))
page_size = fields.Integer()
next_page_token = fields.String(allow_none=True)

@post_load
def build_dto(self, data: dict[str, Any], **_) -> MemberDetails:
return MemberDetails(**data)


@dataclass
class ClassificationData:
severity: str
Expand Down
Empty file.
Loading