Skip to content

Commit 20ff46e

Browse files
authored
CM-23720 - Add table output for all scan types (#122)
1 parent e9813ac commit 20ff46e

File tree

11 files changed

+397
-182
lines changed

11 files changed

+397
-182
lines changed

README.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -211,12 +211,12 @@ repos:
211211

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

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

221221
| Command | Description |
222222
|-------------------------------------|-------------|

cycode/cli/main.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,10 @@
6565
@click.option('--output', default=None,
6666
help="""
6767
\b
68-
Specify the results output (text/json),
68+
Specify the results output (text/json/table),
6969
the default is text
7070
""",
71-
type=click.Choice(['text', 'json']))
71+
type=click.Choice(['text', 'json', 'table']))
7272
@click.option('--severity-threshold',
7373
default=None,
7474
help='Show only violations at the specified level or higher (supported for SCA scan type only).',
@@ -142,8 +142,8 @@ def finalize(context: click.Context, *args, **kwargs):
142142
@click.option(
143143
'--output',
144144
default='text',
145-
help='Specify the output (text/json), the default is text',
146-
type=click.Choice(['text', 'json'])
145+
help='Specify the output (text/json/table), the default is text',
146+
type=click.Choice(['text', 'json', 'table'])
147147
)
148148
@click.option(
149149
'--user-agent',

cycode/cli/printers/base_printer.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77

88

99
class BasePrinter(ABC):
10-
context: click.Context
10+
RED_COLOR_NAME = 'red'
11+
WHITE_COLOR_NAME = 'white'
12+
GREEN_COLOR_NAME = 'green'
1113

1214
def __init__(self, context: click.Context):
1315
self.context = context
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import abc
2+
from typing import List
3+
4+
import click
5+
6+
from cycode.cli.printers.text_printer import TextPrinter
7+
from cycode.cli.models import DocumentDetections, CliError, CliResult
8+
from cycode.cli.printers.base_printer import BasePrinter
9+
10+
11+
class BaseTablePrinter(BasePrinter, abc.ABC):
12+
def __init__(self, context: click.Context):
13+
super().__init__(context)
14+
self.context = context
15+
self.scan_id: str = context.obj.get('scan_id')
16+
self.scan_type: str = context.obj.get('scan_type')
17+
self.show_secret: bool = context.obj.get('show_secret', False)
18+
19+
def print_result(self, result: CliResult) -> None:
20+
TextPrinter(self.context).print_result(result)
21+
22+
def print_error(self, error: CliError) -> None:
23+
TextPrinter(self.context).print_error(error)
24+
25+
def print_scan_results(self, results: List[DocumentDetections]):
26+
click.secho(f'Scan Results: (scan_id: {self.scan_id})')
27+
28+
if not results:
29+
click.secho('Good job! No issues were found!!! 👏👏👏', fg=self.GREEN_COLOR_NAME)
30+
return
31+
32+
self._print_results(results)
33+
34+
report_url = self.context.obj.get('report_url')
35+
if report_url:
36+
click.secho(f'Report URL: {report_url}')
37+
38+
def _is_git_repository(self) -> bool:
39+
return self.context.obj.get('remote_url') is not None
40+
41+
@abc.abstractmethod
42+
def _print_results(self, results: List[DocumentDetections]) -> None:
43+
raise NotImplementedError

cycode/cli/printers/console_printer.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import click
22
from typing import List, TYPE_CHECKING
33

4-
from cycode.cli.consts import SCA_SCAN_TYPE
54
from cycode.cli.exceptions.custom_exceptions import CycodeError
65
from cycode.cli.models import DocumentDetections, CliResult, CliError
76
from cycode.cli.printers.table_printer import TablePrinter
7+
from cycode.cli.printers.sca_table_printer import SCATablePrinter
88
from cycode.cli.printers.json_printer import JsonPrinter
99
from cycode.cli.printers.text_printer import TextPrinter
1010

@@ -16,11 +16,15 @@ class ConsolePrinter:
1616
_AVAILABLE_PRINTERS = {
1717
'text': TextPrinter,
1818
'json': JsonPrinter,
19-
'text_sca': TablePrinter
19+
'table': TablePrinter,
20+
# overrides
21+
'table_sca': SCATablePrinter,
22+
'text_sca': SCATablePrinter,
2023
}
2124

2225
def __init__(self, context: click.Context):
2326
self.context = context
27+
self.scan_type = self.context.obj.get('scan_type')
2428
self.output_type = self.context.obj.get('output')
2529

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

3438
def _get_scan_printer(self) -> 'BasePrinter':
35-
scan_type = self.context.obj.get('scan_type')
36-
3739
printer_class = self._printer_class
38-
if scan_type == SCA_SCAN_TYPE and self.output_type == 'text':
39-
printer_class = TablePrinter
40+
41+
composite_printer = self._AVAILABLE_PRINTERS.get(f'{self.output_type}_{self.scan_type}')
42+
if composite_printer:
43+
printer_class = composite_printer
4044

4145
return printer_class(self.context)
4246

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
from collections import defaultdict
2+
from typing import List, Dict
3+
4+
import click
5+
from texttable import Texttable
6+
7+
from cycode.cli.consts import LICENSE_COMPLIANCE_POLICY_ID, PACKAGE_VULNERABILITY_POLICY_ID
8+
from cycode.cli.models import DocumentDetections, Detection
9+
from cycode.cli.printers.base_table_printer import BaseTablePrinter
10+
11+
SEVERITY_COLUMN = 'Severity'
12+
LICENSE_COLUMN = 'License'
13+
UPGRADE_COLUMN = 'Upgrade'
14+
REPOSITORY_COLUMN = 'Repository'
15+
CVE_COLUMN = 'CVE'
16+
17+
PREVIEW_DETECTIONS_COMMON_HEADERS = [
18+
'File Path',
19+
'Ecosystem',
20+
'Dependency Name',
21+
'Direct Dependency',
22+
'Development Dependency'
23+
]
24+
25+
26+
class SCATablePrinter(BaseTablePrinter):
27+
def _print_results(self, results: List[DocumentDetections]) -> None:
28+
detections_per_detection_type_id = self._extract_detections_per_detection_type_id(results)
29+
self._print_detection_per_detection_type_id(detections_per_detection_type_id)
30+
31+
@staticmethod
32+
def _extract_detections_per_detection_type_id(results: List[DocumentDetections]) -> Dict[str, List[Detection]]:
33+
detections_per_detection_type_id = defaultdict(list)
34+
35+
for document_detection in results:
36+
for detection in document_detection.detections:
37+
detections_per_detection_type_id[detection.detection_type_id].append(detection)
38+
39+
return detections_per_detection_type_id
40+
41+
def _print_detection_per_detection_type_id(
42+
self, detections_per_detection_type_id: Dict[str, List[Detection]]
43+
) -> None:
44+
for detection_type_id in detections_per_detection_type_id:
45+
detections = detections_per_detection_type_id[detection_type_id]
46+
headers = self._get_table_headers()
47+
48+
title = None
49+
rows = []
50+
51+
if detection_type_id == PACKAGE_VULNERABILITY_POLICY_ID:
52+
title = "Dependencies Vulnerabilities"
53+
54+
headers = [SEVERITY_COLUMN] + headers
55+
headers.extend(PREVIEW_DETECTIONS_COMMON_HEADERS)
56+
headers.append(CVE_COLUMN)
57+
headers.append(UPGRADE_COLUMN)
58+
59+
for detection in detections:
60+
rows.append(self._get_upgrade_package_vulnerability(detection))
61+
elif detection_type_id == LICENSE_COMPLIANCE_POLICY_ID:
62+
title = "License Compliance"
63+
64+
headers.extend(PREVIEW_DETECTIONS_COMMON_HEADERS)
65+
headers.append(LICENSE_COLUMN)
66+
67+
for detection in detections:
68+
rows.append(self._get_license(detection))
69+
70+
if rows:
71+
self._print_table_detections(detections, headers, rows, title)
72+
73+
def _get_table_headers(self) -> list:
74+
if self._is_git_repository():
75+
return [REPOSITORY_COLUMN]
76+
77+
return []
78+
79+
def _print_table_detections(
80+
self, detections: List[Detection], headers: List[str], rows, title: str
81+
) -> None:
82+
self._print_summary_issues(detections, title)
83+
text_table = Texttable()
84+
text_table.header(headers)
85+
86+
self.set_table_width(headers, text_table)
87+
88+
for row in rows:
89+
text_table.add_row(row)
90+
91+
click.echo(text_table.draw())
92+
93+
@staticmethod
94+
def set_table_width(headers: List[str], text_table: Texttable) -> None:
95+
header_width_size_cols = []
96+
for header in headers:
97+
header_len = len(header)
98+
if header == CVE_COLUMN:
99+
header_width_size_cols.append(header_len * 5)
100+
elif header == UPGRADE_COLUMN:
101+
header_width_size_cols.append(header_len * 2)
102+
else:
103+
header_width_size_cols.append(header_len)
104+
text_table.set_cols_width(header_width_size_cols)
105+
106+
@staticmethod
107+
def _print_summary_issues(detections: List, title: str) -> None:
108+
click.echo(f'⛔ Found {len(detections)} issues of type: {click.style(title, bold=True)}')
109+
110+
def _get_common_detection_fields(self, detection: Detection) -> List[str]:
111+
row = [
112+
detection.detection_details.get('file_name'),
113+
detection.detection_details.get('ecosystem'),
114+
detection.detection_details.get('package_name'),
115+
detection.detection_details.get('is_direct_dependency_str'),
116+
detection.detection_details.get('is_dev_dependency_str')
117+
]
118+
119+
if self._is_git_repository():
120+
row = [detection.detection_details.get('repository_name')] + row
121+
122+
return row
123+
124+
def _get_upgrade_package_vulnerability(self, detection: Detection) -> List[str]:
125+
alert = detection.detection_details.get('alert')
126+
row = [
127+
detection.detection_details.get('advisory_severity'),
128+
*self._get_common_detection_fields(detection),
129+
detection.detection_details.get('vulnerability_id')
130+
]
131+
132+
upgrade = ''
133+
if alert.get("first_patched_version"):
134+
upgrade = f'{alert.get("vulnerable_requirements")} -> {alert.get("first_patched_version")}'
135+
row.append(upgrade)
136+
137+
return row
138+
139+
def _get_license(self, detection: Detection) -> List[str]:
140+
row = self._get_common_detection_fields(detection)
141+
row.append(f'{detection.detection_details.get("license")}')
142+
return row

cycode/cli/printers/table.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from typing import List, Dict, Optional, TYPE_CHECKING
2+
from texttable import Texttable
3+
4+
if TYPE_CHECKING:
5+
from cycode.cli.printers.table_models import ColumnInfo, ColumnWidths
6+
7+
8+
class Table:
9+
"""Helper class to manage columns and their values in the right order and only if the column should be presented."""
10+
11+
def __init__(self, column_infos: Optional[List['ColumnInfo']] = None):
12+
self._column_widths = None
13+
14+
self._columns: Dict['ColumnInfo', List[str]] = dict()
15+
if column_infos:
16+
self._columns: Dict['ColumnInfo', List[str]] = {columns: list() for columns in column_infos}
17+
18+
def add(self, column: 'ColumnInfo') -> None:
19+
self._columns[column] = list()
20+
21+
def set(self, column: 'ColumnInfo', value: str) -> None:
22+
# we push values only for existing columns what were added before
23+
if column in self._columns:
24+
self._columns[column].append(value)
25+
26+
def _get_ordered_columns(self) -> List['ColumnInfo']:
27+
# we are sorting columns by index to make sure that columns will be printed in the right order
28+
return sorted(self._columns, key=lambda column_info: column_info.index)
29+
30+
def get_columns_info(self) -> List['ColumnInfo']:
31+
return self._get_ordered_columns()
32+
33+
def get_headers(self) -> List[str]:
34+
return [header.name for header in self._get_ordered_columns()]
35+
36+
def get_rows(self) -> List[str]:
37+
column_values = [self._columns[column_info] for column_info in self._get_ordered_columns()]
38+
return list(zip(*column_values))
39+
40+
def set_cols_width(self, column_widths: 'ColumnWidths') -> None:
41+
header_width_size = []
42+
for header in self.get_columns_info():
43+
width_multiplier = 1
44+
if header in column_widths:
45+
width_multiplier = column_widths[header]
46+
47+
header_width_size.append(len(header.name) * width_multiplier)
48+
49+
self._column_widths = header_width_size
50+
51+
def get_table(self, max_width: int = 80) -> Texttable:
52+
table = Texttable(max_width)
53+
table.header(self.get_headers())
54+
55+
for row in self.get_rows():
56+
table.add_row(row)
57+
58+
if self._column_widths:
59+
table.set_cols_width(self._column_widths)
60+
61+
return table
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from typing import NamedTuple, Dict
2+
3+
4+
class ColumnInfoBuilder:
5+
_index = 0
6+
7+
@staticmethod
8+
def build(name: str) -> 'ColumnInfo':
9+
column_info = ColumnInfo(name, ColumnInfoBuilder._index)
10+
ColumnInfoBuilder._index += 1
11+
return column_info
12+
13+
14+
class ColumnInfo(NamedTuple):
15+
name: str
16+
index: int # Represents the order of the columns, starting from the left
17+
18+
19+
ColumnWidths = Dict[ColumnInfo, int]
20+
ColumnWidthsConfig = Dict[str, ColumnWidths]

0 commit comments

Comments
 (0)