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
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,12 +211,12 @@ repos:

The following are the options and commands available with the Cycode CLI application:

| Option | Description |
|-------------------------|-----------------------------------------------------------|
| `--output [text\|json]` | Specify the output (`text`/`json`). The default is `text` |
| `-v`, `--verbose` | Show detailed logs |
| `--version` | Show the version and exit. |
| `--help` | Show options for given command. |
| Option | Description |
|--------------------------------|-------------------------------------------------------------------|
| `--output [text\|json\|table]` | Specify the output (`text`/`json`/`table`). The default is `text` |
| `-v`, `--verbose` | Show detailed logs |
| `--version` | Show the version and exit. |
| `--help` | Show options for given command. |

| Command | Description |
|-------------------------------------|-------------|
Expand Down
8 changes: 4 additions & 4 deletions cycode/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,10 @@
@click.option('--output', default=None,
help="""
\b
Specify the results output (text/json),
Specify the results output (text/json/table),
the default is text
""",
type=click.Choice(['text', 'json']))
type=click.Choice(['text', 'json', 'table']))
@click.option('--severity-threshold',
default=None,
help='Show only violations at the specified level or higher (supported for SCA scan type only).',
Expand Down Expand Up @@ -142,8 +142,8 @@ def finalize(context: click.Context, *args, **kwargs):
@click.option(
'--output',
default='text',
help='Specify the output (text/json), the default is text',
type=click.Choice(['text', 'json'])
help='Specify the output (text/json/table), the default is text',
type=click.Choice(['text', 'json', 'table'])
)
@click.option(
'--user-agent',
Expand Down
4 changes: 3 additions & 1 deletion cycode/cli/printers/base_printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@


class BasePrinter(ABC):
context: click.Context
RED_COLOR_NAME = 'red'
WHITE_COLOR_NAME = 'white'
GREEN_COLOR_NAME = 'green'

def __init__(self, context: click.Context):
self.context = context
Expand Down
43 changes: 43 additions & 0 deletions cycode/cli/printers/base_table_printer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import abc
from typing import List

import click

from cycode.cli.printers.text_printer import TextPrinter
from cycode.cli.models import DocumentDetections, CliError, CliResult
from cycode.cli.printers.base_printer import BasePrinter


class BaseTablePrinter(BasePrinter, abc.ABC):
def __init__(self, context: click.Context):
super().__init__(context)
self.context = context
self.scan_id: str = context.obj.get('scan_id')
self.scan_type: str = context.obj.get('scan_type')
self.show_secret: bool = context.obj.get('show_secret', False)

def print_result(self, result: CliResult) -> None:
TextPrinter(self.context).print_result(result)

def print_error(self, error: CliError) -> None:
TextPrinter(self.context).print_error(error)

def print_scan_results(self, results: List[DocumentDetections]):
click.secho(f'Scan Results: (scan_id: {self.scan_id})')

if not results:
click.secho('Good job! No issues were found!!! 👏👏👏', fg=self.GREEN_COLOR_NAME)
return

self._print_results(results)

report_url = self.context.obj.get('report_url')
if report_url:
click.secho(f'Report URL: {report_url}')

def _is_git_repository(self) -> bool:
return self.context.obj.get('remote_url') is not None

@abc.abstractmethod
def _print_results(self, results: List[DocumentDetections]) -> None:
raise NotImplementedError
16 changes: 10 additions & 6 deletions cycode/cli/printers/console_printer.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import click
from typing import List, TYPE_CHECKING

from cycode.cli.consts import SCA_SCAN_TYPE
from cycode.cli.exceptions.custom_exceptions import CycodeError
from cycode.cli.models import DocumentDetections, CliResult, CliError
from cycode.cli.printers.table_printer import TablePrinter
from cycode.cli.printers.sca_table_printer import SCATablePrinter
from cycode.cli.printers.json_printer import JsonPrinter
from cycode.cli.printers.text_printer import TextPrinter

Expand All @@ -16,11 +16,15 @@ class ConsolePrinter:
_AVAILABLE_PRINTERS = {
'text': TextPrinter,
'json': JsonPrinter,
'text_sca': TablePrinter
'table': TablePrinter,
# overrides
'table_sca': SCATablePrinter,
'text_sca': SCATablePrinter,
}

def __init__(self, context: click.Context):
self.context = context
self.scan_type = self.context.obj.get('scan_type')
self.output_type = self.context.obj.get('output')

self._printer_class = self._AVAILABLE_PRINTERS.get(self.output_type)
Expand All @@ -32,11 +36,11 @@ def print_scan_results(self, detections_results_list: List[DocumentDetections])
printer.print_scan_results(detections_results_list)

def _get_scan_printer(self) -> 'BasePrinter':
scan_type = self.context.obj.get('scan_type')

printer_class = self._printer_class
if scan_type == SCA_SCAN_TYPE and self.output_type == 'text':
printer_class = TablePrinter

composite_printer = self._AVAILABLE_PRINTERS.get(f'{self.output_type}_{self.scan_type}')
if composite_printer:
printer_class = composite_printer

return printer_class(self.context)

Expand Down
142 changes: 142 additions & 0 deletions cycode/cli/printers/sca_table_printer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
from collections import defaultdict
from typing import List, Dict

import click
from texttable import Texttable

from cycode.cli.consts import LICENSE_COMPLIANCE_POLICY_ID, PACKAGE_VULNERABILITY_POLICY_ID
from cycode.cli.models import DocumentDetections, Detection
from cycode.cli.printers.base_table_printer import BaseTablePrinter

SEVERITY_COLUMN = 'Severity'
LICENSE_COLUMN = 'License'
UPGRADE_COLUMN = 'Upgrade'
REPOSITORY_COLUMN = 'Repository'
CVE_COLUMN = 'CVE'

PREVIEW_DETECTIONS_COMMON_HEADERS = [
'File Path',
'Ecosystem',
'Dependency Name',
'Direct Dependency',
'Development Dependency'
]


class SCATablePrinter(BaseTablePrinter):
def _print_results(self, results: List[DocumentDetections]) -> None:
detections_per_detection_type_id = self._extract_detections_per_detection_type_id(results)
self._print_detection_per_detection_type_id(detections_per_detection_type_id)

@staticmethod
def _extract_detections_per_detection_type_id(results: List[DocumentDetections]) -> Dict[str, List[Detection]]:
detections_per_detection_type_id = defaultdict(list)

for document_detection in results:
for detection in document_detection.detections:
detections_per_detection_type_id[detection.detection_type_id].append(detection)

return detections_per_detection_type_id

def _print_detection_per_detection_type_id(
self, detections_per_detection_type_id: Dict[str, List[Detection]]
) -> None:
for detection_type_id in detections_per_detection_type_id:
detections = detections_per_detection_type_id[detection_type_id]
headers = self._get_table_headers()

title = None
rows = []

if detection_type_id == PACKAGE_VULNERABILITY_POLICY_ID:
title = "Dependencies Vulnerabilities"

headers = [SEVERITY_COLUMN] + headers
headers.extend(PREVIEW_DETECTIONS_COMMON_HEADERS)
headers.append(CVE_COLUMN)
headers.append(UPGRADE_COLUMN)

for detection in detections:
rows.append(self._get_upgrade_package_vulnerability(detection))
elif detection_type_id == LICENSE_COMPLIANCE_POLICY_ID:
title = "License Compliance"

headers.extend(PREVIEW_DETECTIONS_COMMON_HEADERS)
headers.append(LICENSE_COLUMN)

for detection in detections:
rows.append(self._get_license(detection))

if rows:
self._print_table_detections(detections, headers, rows, title)

def _get_table_headers(self) -> list:
if self._is_git_repository():
return [REPOSITORY_COLUMN]

return []

def _print_table_detections(
self, detections: List[Detection], headers: List[str], rows, title: str
) -> None:
self._print_summary_issues(detections, title)
text_table = Texttable()
text_table.header(headers)

self.set_table_width(headers, text_table)

for row in rows:
text_table.add_row(row)

click.echo(text_table.draw())

@staticmethod
def set_table_width(headers: List[str], text_table: Texttable) -> None:
header_width_size_cols = []
for header in headers:
header_len = len(header)
if header == CVE_COLUMN:
header_width_size_cols.append(header_len * 5)
elif header == UPGRADE_COLUMN:
header_width_size_cols.append(header_len * 2)
else:
header_width_size_cols.append(header_len)
text_table.set_cols_width(header_width_size_cols)

@staticmethod
def _print_summary_issues(detections: List, title: str) -> None:
click.echo(f'⛔ Found {len(detections)} issues of type: {click.style(title, bold=True)}')

def _get_common_detection_fields(self, detection: Detection) -> List[str]:
row = [
detection.detection_details.get('file_name'),
detection.detection_details.get('ecosystem'),
detection.detection_details.get('package_name'),
detection.detection_details.get('is_direct_dependency_str'),
detection.detection_details.get('is_dev_dependency_str')
]

if self._is_git_repository():
row = [detection.detection_details.get('repository_name')] + row

return row

def _get_upgrade_package_vulnerability(self, detection: Detection) -> List[str]:
alert = detection.detection_details.get('alert')
row = [
detection.detection_details.get('advisory_severity'),
*self._get_common_detection_fields(detection),
detection.detection_details.get('vulnerability_id')
]

upgrade = ''
if alert.get("first_patched_version"):
upgrade = f'{alert.get("vulnerable_requirements")} -> {alert.get("first_patched_version")}'
row.append(upgrade)

return row

def _get_license(self, detection: Detection) -> List[str]:
row = self._get_common_detection_fields(detection)
row.append(f'{detection.detection_details.get("license")}')
return row
61 changes: 61 additions & 0 deletions cycode/cli/printers/table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from typing import List, Dict, Optional, TYPE_CHECKING
from texttable import Texttable

if TYPE_CHECKING:
from cycode.cli.printers.table_models import ColumnInfo, ColumnWidths


class Table:
"""Helper class to manage columns and their values in the right order and only if the column should be presented."""

def __init__(self, column_infos: Optional[List['ColumnInfo']] = None):
self._column_widths = None

self._columns: Dict['ColumnInfo', List[str]] = dict()
if column_infos:
self._columns: Dict['ColumnInfo', List[str]] = {columns: list() for columns in column_infos}

def add(self, column: 'ColumnInfo') -> None:
self._columns[column] = list()

def set(self, column: 'ColumnInfo', value: str) -> None:
# we push values only for existing columns what were added before
if column in self._columns:
self._columns[column].append(value)

def _get_ordered_columns(self) -> List['ColumnInfo']:
# we are sorting columns by index to make sure that columns will be printed in the right order
return sorted(self._columns, key=lambda column_info: column_info.index)

def get_columns_info(self) -> List['ColumnInfo']:
return self._get_ordered_columns()

def get_headers(self) -> List[str]:
return [header.name for header in self._get_ordered_columns()]

def get_rows(self) -> List[str]:
column_values = [self._columns[column_info] for column_info in self._get_ordered_columns()]
return list(zip(*column_values))

def set_cols_width(self, column_widths: 'ColumnWidths') -> None:
header_width_size = []
for header in self.get_columns_info():
width_multiplier = 1
if header in column_widths:
width_multiplier = column_widths[header]

header_width_size.append(len(header.name) * width_multiplier)

self._column_widths = header_width_size

def get_table(self, max_width: int = 80) -> Texttable:
table = Texttable(max_width)
table.header(self.get_headers())

for row in self.get_rows():
table.add_row(row)

if self._column_widths:
table.set_cols_width(self._column_widths)

return table
20 changes: 20 additions & 0 deletions cycode/cli/printers/table_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from typing import NamedTuple, Dict


class ColumnInfoBuilder:
_index = 0

@staticmethod
def build(name: str) -> 'ColumnInfo':
column_info = ColumnInfo(name, ColumnInfoBuilder._index)
ColumnInfoBuilder._index += 1
return column_info


class ColumnInfo(NamedTuple):
name: str
index: int # Represents the order of the columns, starting from the left


ColumnWidths = Dict[ColumnInfo, int]
ColumnWidthsConfig = Dict[str, ColumnWidths]
Loading