diff --git a/README.md b/README.md index 627f2c31..da428ec8 100644 --- a/README.md +++ b/README.md @@ -789,7 +789,7 @@ The Cycode CLI application offers several types of scans so that you can choose | `--show-secret BOOLEAN` | Show secrets in plain text. See [Show/Hide Secrets](#showhide-secrets) section for more details. | | `--soft-fail BOOLEAN` | Run scan without failing, always return a non-error status code. See [Soft Fail](#soft-fail) section for more details. | | `--severity-threshold [INFO\|LOW\|MEDIUM\|HIGH\|CRITICAL]` | Show only violations at the specified level or higher. | -| `--sca-scan` | Specify the SCA scan you wish to execute (`package-vulnerabilities`/`license-compliance`). The default is both. | +| `--sca-scan` | Specify the SCA scan you wish to execute (`package-vulnerabilities`/`license-compliance`/`unmaintained-packages`). The default is all. | | `--monitor` | When specified, the scan results will be recorded in Cycode. | | `--cycode-report` | Display a link to the scan report in the Cycode platform in the console output. | | `--no-restore` | When specified, Cycode will not run the restore command. This will scan direct dependencies ONLY! | @@ -867,6 +867,20 @@ In the previous example, if you wanted to only scan a branch named `dev`, you co `cycode scan -t sca --sca-scan license-compliance repository ~/home/git/codebase -b dev` +#### Unmaintained Packages Option + +> [!NOTE] +> This option is only available to SCA scans. + +To scan only for unmaintained packages (packages whose [OpenSSF Scorecard](https://scorecard.dev) `Maintained` check is low, meaning little or no recent commit and issue activity), add the argument `--sca-scan unmaintained-packages` following the `-t sca` or `--scan-type sca` option. + +> [!NOTE] +> Whether unmaintained packages are reported at all is controlled by your organization's policy. This option narrows what a scan reports; it cannot enable a policy that is turned off for your tenant. + +In the previous example, if you wanted to only run an SCA scan on unmaintained packages, you could execute the following: + +`cycode scan -t sca --sca-scan unmaintained-packages repository ~/home/git/codebase` + #### Lock Restore Option > [!NOTE] diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 427f2d78..48c425b9 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -93,7 +93,11 @@ def scan_command( help='Specify the type of SCA scan you wish to execute.', rich_help_panel=_SCA_RICH_HELP_PANEL, ), - ] = (ScaScanTypeOption.PACKAGE_VULNERABILITIES, ScaScanTypeOption.LICENSE_COMPLIANCE), + ] = ( + ScaScanTypeOption.PACKAGE_VULNERABILITIES, + ScaScanTypeOption.LICENSE_COMPLIANCE, + ScaScanTypeOption.UNMAINTAINED_PACKAGES, + ), monitor: Annotated[ bool, typer.Option( diff --git a/cycode/cli/apps/scan/scan_parameters.py b/cycode/cli/apps/scan/scan_parameters.py index f362d419..d297010a 100644 --- a/cycode/cli/apps/scan/scan_parameters.py +++ b/cycode/cli/apps/scan/scan_parameters.py @@ -16,6 +16,7 @@ def _get_default_scan_parameters(ctx: typer.Context) -> dict: 'report': ctx.obj.get('report'), 'package_vulnerabilities': ctx.obj.get('package-vulnerabilities'), 'license_compliance': ctx.obj.get('license-compliance'), + 'maintainability': ctx.obj.get('unmaintained-packages', False), 'command_type': ctx.info_name.replace('-', '_'), # save backward compatibility 'aggregation_id': str(generate_unique_scan_id()), 'cli_start_time': _BOOT_WALL, diff --git a/cycode/cli/cli_types.py b/cycode/cli/cli_types.py index ed277cc6..8b68d7af 100644 --- a/cycode/cli/cli_types.py +++ b/cycode/cli/cli_types.py @@ -40,6 +40,7 @@ def __str__(self) -> str: class ScaScanTypeOption(StrEnum): PACKAGE_VULNERABILITIES = 'package-vulnerabilities' LICENSE_COMPLIANCE = 'license-compliance' + UNMAINTAINED_PACKAGES = 'unmaintained-packages' class SbomFormatOption(StrEnum): diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 7272dae3..104cfc9b 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -314,6 +314,7 @@ LICENSE_COMPLIANCE_POLICY_ID = '8f681450-49e1-4f7e-85b7-0c8fe84b3a35' PACKAGE_VULNERABILITY_POLICY_ID = '9369d10a-9ac0-48d3-9921-5de7fe9a37a7' +UNMAINTAINED_PACKAGE_POLICY_ID = '7b45ee1f-ee08-4353-a00a-2586db27b0f1' # Shortcut dependency paths by remove all middle dependencies # between direct dependency and influence/vulnerable dependency. diff --git a/cycode/cli/printers/rich_printer.py b/cycode/cli/printers/rich_printer.py index 10cf561c..abbca49e 100644 --- a/cycode/cli/printers/rich_printer.py +++ b/cycode/cli/printers/rich_printer.py @@ -16,6 +16,7 @@ ) from cycode.cli.printers.utils.detection_ordering.common_ordering import sort_and_group_detections_from_scan_result from cycode.cli.printers.utils.rich_helpers import get_columns_in_1_to_3_ratio, get_markdown_panel, get_panel +from cycode.cli.printers.utils.sca_ossf import get_maintained_score, get_ossf_report_url, get_ossf_score if TYPE_CHECKING: from cycode.cli.models import CliError, Detection, Document, LocalScanResult @@ -97,7 +98,13 @@ def __add_sca_scan_related_rows(details_table: Table, detection: 'Detection') -> dependency_path = detection_details.get('dependency_paths') details_table.add_row('Dependency path', dependency_path or 'N/A') - if not detection.has_alert: + if detection.detection_type_id == consts.UNMAINTAINED_PACKAGE_POLICY_ID: + maintained_score = get_maintained_score(detection_details) + ossf_score = get_ossf_score(detection_details) + details_table.add_row('Maintained score', 'N/A' if maintained_score is None else str(maintained_score)) + details_table.add_row('OSSF Scorecard score', 'N/A' if ossf_score is None else str(ossf_score)) + details_table.add_row('Scorecard report', get_ossf_report_url(detection_details) or 'N/A') + elif not detection.has_alert: details_table.add_row('License', detection_details.get('license')) @staticmethod diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index 064d21d1..34deba59 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -2,13 +2,18 @@ from typing import TYPE_CHECKING from cycode.cli.cli_types import SeverityOption -from cycode.cli.consts import LICENSE_COMPLIANCE_POLICY_ID, PACKAGE_VULNERABILITY_POLICY_ID +from cycode.cli.consts import ( + LICENSE_COMPLIANCE_POLICY_ID, + PACKAGE_VULNERABILITY_POLICY_ID, + UNMAINTAINED_PACKAGE_POLICY_ID, +) from cycode.cli.models import Detection from cycode.cli.printers.tables.table import Table from cycode.cli.printers.tables.table_models import ColumnInfoBuilder from cycode.cli.printers.tables.table_printer_base import TablePrinterBase from cycode.cli.printers.utils import is_git_diff_based_scan from cycode.cli.printers.utils.detection_ordering.sca_ordering import sort_and_group_detections +from cycode.cli.printers.utils.sca_ossf import get_maintained_score from cycode.cli.utils.string_utils import shortcut_dependency_paths if TYPE_CHECKING: @@ -23,6 +28,7 @@ ECOSYSTEM_COLUMN = column_builder.build(name='Ecosystem', highlight=False) PACKAGE_COLUMN = column_builder.build(name='Package', highlight=False) CVE_COLUMNS = column_builder.build(name='CVE', highlight=False) +MAINTAINED_SCORE_COLUMN = column_builder.build(name='Maintained Score', highlight=False) DEPENDENCY_PATHS_COLUMN = column_builder.build(name='Dependency Paths') UPGRADE_COLUMN = column_builder.build(name='Upgrade') LICENSE_COLUMN = column_builder.build(name='License', highlight=False) @@ -51,6 +57,8 @@ def _get_title(policy_id: str) -> str: return 'Dependency Vulnerabilities' if policy_id == LICENSE_COMPLIANCE_POLICY_ID: return 'License Compliance' + if policy_id == UNMAINTAINED_PACKAGE_POLICY_ID: + return 'Unmaintained Packages' return 'Unknown' @@ -62,6 +70,8 @@ def _get_table(self, policy_id: str) -> Table: table.add_column(UPGRADE_COLUMN) elif policy_id == LICENSE_COMPLIANCE_POLICY_ID: table.add_column(LICENSE_COLUMN) + elif policy_id == UNMAINTAINED_PACKAGE_POLICY_ID: + table.add_column(MAINTAINED_SCORE_COLUMN) if is_git_diff_based_scan(self.command_scan_type): table.add_column(REPOSITORY_COLUMN) @@ -120,6 +130,9 @@ def _enrich_table_with_values(table: Table, detection: Detection) -> None: table.add_cell(CVE_COLUMNS, detection_details.get('vulnerability_id')) table.add_cell(LICENSE_COLUMN, detection_details.get('license')) + maintained_score = get_maintained_score(detection_details) + table.add_cell(MAINTAINED_SCORE_COLUMN, 'N/A' if maintained_score is None else str(maintained_score)) + def _print_summary_issues(self, detections_count: int, title: str) -> None: self.console.print(f'[bold]Cycode found {detections_count} violations of type: [cyan]{title}[/]') diff --git a/cycode/cli/printers/text_printer.py b/cycode/cli/printers/text_printer.py index 51da53c5..1397933a 100644 --- a/cycode/cli/printers/text_printer.py +++ b/cycode/cli/printers/text_printer.py @@ -7,6 +7,7 @@ from cycode.cli.printers.utils.code_snippet_syntax import get_code_snippet_syntax, get_detection_line from cycode.cli.printers.utils.detection_data import get_detection_title from cycode.cli.printers.utils.detection_ordering.common_ordering import sort_and_group_detections_from_scan_result +from cycode.cli.printers.utils.sca_ossf import get_maintained_score, get_ossf_report_url, get_ossf_score if TYPE_CHECKING: from cycode.cli.models import Detection, LocalScanResult @@ -84,7 +85,17 @@ def __get_intermediate_summary_lines(self, detection: 'Detection') -> list[str]: def __get_sca_related_summary_lines(detection: 'Detection') -> list[str]: summary_lines = [] - if detection.has_alert: + if detection.detection_type_id == consts.UNMAINTAINED_PACKAGE_POLICY_ID: + maintained_score = get_maintained_score(detection.detection_details) + ossf_score = get_ossf_score(detection.detection_details) + maintained = 'N/A' if maintained_score is None else maintained_score + score = 'N/A' if ossf_score is None else ossf_score + report_url = get_ossf_report_url(detection.detection_details) or 'N/A' + + summary_lines.append(f'Maintained score: [cyan]{maintained}[/]\n') + summary_lines.append(f'OSSF Scorecard score: [cyan]{score}[/]\n') + summary_lines.append(f'Scorecard report: [cyan]{report_url}[/]\n') + elif detection.has_alert: patched_version = detection.detection_details['alert'].get('first_patched_version') patched_version = patched_version or 'Not fixed' diff --git a/cycode/cli/printers/utils/sca_ossf.py b/cycode/cli/printers/utils/sca_ossf.py new file mode 100644 index 00000000..11d3f662 --- /dev/null +++ b/cycode/cli/printers/utils/sca_ossf.py @@ -0,0 +1,23 @@ +from typing import Any, Optional + +_MAINTAINED_CHECK_NAME = 'maintained' + + +def _get_ossf_details(detection_details: dict) -> dict: + return detection_details.get('ossf') or {} + + +def get_ossf_score(detection_details: dict) -> Optional[Any]: + return _get_ossf_details(detection_details).get('score') + + +def get_ossf_report_url(detection_details: dict) -> Optional[str]: + return _get_ossf_details(detection_details).get('scorecard_report_url') + + +def get_maintained_score(detection_details: dict) -> Optional[Any]: + for check in _get_ossf_details(detection_details).get('checks') or []: + if str(check.get('name', '')).lower() == _MAINTAINED_CHECK_NAME: + return check.get('score') + + return None diff --git a/tests/cli/commands/scan/test_scan_parameters.py b/tests/cli/commands/scan/test_scan_parameters.py index 6933e9bc..a0286e31 100644 --- a/tests/cli/commands/scan/test_scan_parameters.py +++ b/tests/cli/commands/scan/test_scan_parameters.py @@ -14,6 +14,7 @@ def mock_context() -> MagicMock: 'report': False, 'package-vulnerabilities': True, 'license-compliance': True, + 'unmaintained-packages': True, } ctx.info_name = 'test-command' return ctx @@ -27,6 +28,7 @@ def test_get_default_scan_parameters(mock_context: MagicMock) -> None: assert params['report'] is False assert params['package_vulnerabilities'] is True assert params['license_compliance'] is True + assert params['maintainability'] is True assert params['command_type'] == 'test_command' # hyphens replaced with underscores assert 'aggregation_id' in params @@ -113,3 +115,30 @@ def test_get_scan_parameters_branch_with_various_names(mock_get_remote_url: Magi mock_context.obj['branch'] = 'release-v1.0.0' params = get_scan_parameters(mock_context, paths) assert params['branch'] == 'release-v1.0.0' + + +def test_get_default_scan_parameters_maintainability_uses_unmaintained_packages_context_key( + mock_context: MagicMock, +) -> None: + """Test that the maintainability wire parameter is taken from the unmaintained-packages context key.""" + mock_context.obj['unmaintained-packages'] = False + + params = _get_default_scan_parameters(mock_context) + + assert params['maintainability'] is False + assert 'unmaintained_packages' not in params + + +def test_get_default_scan_parameters_maintainability_filters_out_when_not_selected( + mock_context: MagicMock, +) -> None: + """Test that narrowing --sca-scan sends an explicit False rather than omitting the parameter. + + The backend treats a missing value as "no opinion" so that CLI versions predating the option still get the + policy. A narrowed selection is an opinion, so it has to say False out loud. + """ + mock_context.obj.pop('unmaintained-packages') + + params = _get_default_scan_parameters(mock_context) + + assert params['maintainability'] is False diff --git a/tests/cli/printers/test_sca_table_printer.py b/tests/cli/printers/test_sca_table_printer.py new file mode 100644 index 00000000..37fbfdba --- /dev/null +++ b/tests/cli/printers/test_sca_table_printer.py @@ -0,0 +1,112 @@ +from unittest.mock import MagicMock + +import pytest +from rich.console import Console + +from cycode.cli.consts import ( + LICENSE_COMPLIANCE_POLICY_ID, + PACKAGE_VULNERABILITY_POLICY_ID, + UNMAINTAINED_PACKAGE_POLICY_ID, +) +from cycode.cli.printers.tables.sca_table_printer import ( + CVE_COLUMNS, + LICENSE_COLUMN, + MAINTAINED_SCORE_COLUMN, + UPGRADE_COLUMN, + ScaTablePrinter, +) +from cycode.cyclient.models import Detection + + +@pytest.fixture +def printer() -> ScaTablePrinter: + ctx = MagicMock() + ctx.obj = {'scan_type': 'sca'} + ctx.info_name = 'path' + return ScaTablePrinter(ctx, Console(), Console(stderr=True)) + + +def _make_detection(policy_id: str, **details: object) -> Detection: + return Detection( + detection_type_id=policy_id, + type='Unmaintained packages', + message='Package is unmaintained', + detection_details=dict(details), + detection_rule_id='rule-id', + severity='Medium', + ) + + +def test_get_title_unmaintained_packages() -> None: + assert ScaTablePrinter._get_title(UNMAINTAINED_PACKAGE_POLICY_ID) == 'Unmaintained Packages' + + +def test_get_title_known_policies_are_not_changed() -> None: + assert ScaTablePrinter._get_title(PACKAGE_VULNERABILITY_POLICY_ID) == 'Dependency Vulnerabilities' + assert ScaTablePrinter._get_title(LICENSE_COMPLIANCE_POLICY_ID) == 'License Compliance' + + +def test_get_title_unknown_policy() -> None: + assert ScaTablePrinter._get_title('not-a-known-policy-id') == 'Unknown' + + +def test_get_table_unmaintained_packages_columns(printer: ScaTablePrinter) -> None: + columns = printer._get_table(UNMAINTAINED_PACKAGE_POLICY_ID).get_columns_info() + + assert MAINTAINED_SCORE_COLUMN in columns + assert CVE_COLUMNS not in columns + assert UPGRADE_COLUMN not in columns + assert LICENSE_COLUMN not in columns + + +def test_get_table_unmaintained_packages_column_order(printer: ScaTablePrinter) -> None: + column_names = [column.name for column in printer._get_table(UNMAINTAINED_PACKAGE_POLICY_ID).get_columns_info()] + + assert column_names == [ + 'Severity', + 'Code Project', + 'Ecosystem', + 'Package', + 'Maintained Score', + 'Dependency Paths', + 'Direct Dependency', + 'Development Dependency', + ] + + +def test_get_table_other_policies_do_not_get_the_score_column(printer: ScaTablePrinter) -> None: + assert MAINTAINED_SCORE_COLUMN not in printer._get_table(PACKAGE_VULNERABILITY_POLICY_ID).get_columns_info() + assert MAINTAINED_SCORE_COLUMN not in printer._get_table(LICENSE_COMPLIANCE_POLICY_ID).get_columns_info() + + +def test_enrich_table_with_values_populates_the_score(printer: ScaTablePrinter) -> None: + table = printer._get_table(UNMAINTAINED_PACKAGE_POLICY_ID) + detection = _make_detection( + UNMAINTAINED_PACKAGE_POLICY_ID, + file_path='/repo/package.json', + ecosystem='npm', + package_name='left-pad', + package_version='1.0.0', + ossf={ + 'score': 4.1, + 'scorecard_report_url': 'https://scorecard.dev/viewer/?uri=github.com/example/left-pad', + 'checks': [{'name': 'Maintained', 'score': 1.5, 'reason': 'no recent activity'}], + }, + ) + + ScaTablePrinter._enrich_table_with_values(table, detection) + + row = table.get_rows()[0] + score_index = table.get_columns_info().index(MAINTAINED_SCORE_COLUMN) + assert row[score_index] == '1.5' + + +def test_enrich_table_with_values_missing_score(printer: ScaTablePrinter) -> None: + table = printer._get_table(UNMAINTAINED_PACKAGE_POLICY_ID) + detection = _make_detection(UNMAINTAINED_PACKAGE_POLICY_ID, file_path='/repo/package.json', package_name='left-pad') + + ScaTablePrinter._enrich_table_with_values(table, detection) + + row = table.get_rows()[0] + score_index = table.get_columns_info().index(MAINTAINED_SCORE_COLUMN) + assert row[score_index] == 'N/A' diff --git a/tests/cli/printers/test_text_printer.py b/tests/cli/printers/test_text_printer.py new file mode 100644 index 00000000..f973ca93 --- /dev/null +++ b/tests/cli/printers/test_text_printer.py @@ -0,0 +1,129 @@ +import io +from unittest.mock import MagicMock + +import pytest +from rich.console import Console + +from cycode.cli.consts import ( + LICENSE_COMPLIANCE_POLICY_ID, + PACKAGE_VULNERABILITY_POLICY_ID, + UNMAINTAINED_PACKAGE_POLICY_ID, +) +from cycode.cli.models import Document, DocumentDetections, LocalScanResult +from cycode.cli.printers.text_printer import TextPrinter +from cycode.cyclient.models import Detection + + +@pytest.fixture +def output() -> io.StringIO: + return io.StringIO() + + +@pytest.fixture +def printer(output: io.StringIO) -> TextPrinter: + ctx = MagicMock() + ctx.obj = {'scan_type': 'sca', 'show_secret': False} + ctx.info_name = 'path' + return TextPrinter(ctx, Console(file=output, width=200), Console(stderr=True)) + + +def _make_detection(policy_id: str, **details: object) -> Detection: + return Detection( + detection_type_id=policy_id, + type='UnmaintainedPackage', + message='Package is unmaintained', + detection_details=dict(details), + detection_rule_id='rule-id', + severity='Medium', + ) + + +def _render(printer: TextPrinter, output: io.StringIO, detection: Detection) -> str: + document = Document(path='package-lock.json', content='{}') + printer.print_scan_results( + [ + LocalScanResult( + scan_id='scan-id', + report_url=None, + document_detections=[DocumentDetections(document=document, detections=[detection])], + issue_detected=True, + detections_count=1, + relevant_detections_count=1, + ) + ] + ) + return output.getvalue() + + +def test_unmaintained_package_prints_the_score_and_report(printer: TextPrinter, output: io.StringIO) -> None: + detection = _make_detection( + UNMAINTAINED_PACKAGE_POLICY_ID, + ossf={ + 'score': 4.1, + 'scorecard_report_url': 'https://scorecard.dev/viewer/?uri=github.com/a/b', + 'checks': [{'name': 'Maintained', 'score': 0, 'reason': '0 commit(s) in the last 90 days'}], + }, + ) + + result = _render(printer, output, detection) + + assert 'Maintained score: 0' in result + assert 'OSSF Scorecard score: 4.1' in result + assert 'Scorecard report: https://scorecard.dev/viewer/?uri=github.com/a/b' in result + assert 'License' not in result + + +def test_unmaintained_package_without_ossf_details(printer: TextPrinter, output: io.StringIO) -> None: + detection = _make_detection(UNMAINTAINED_PACKAGE_POLICY_ID) + + result = _render(printer, output, detection) + + assert 'Maintained score: N/A' in result + assert 'OSSF Scorecard score: N/A' in result + assert 'Scorecard report: N/A' in result + + +def test_unmaintained_package_with_zero_score(printer: TextPrinter, output: io.StringIO) -> None: + detection = _make_detection( + UNMAINTAINED_PACKAGE_POLICY_ID, + ossf={'score': 0, 'scorecard_report_url': '', 'checks': [{'name': 'Maintained', 'score': 0}]}, + ) + + result = _render(printer, output, detection) + + assert 'Maintained score: 0' in result + assert 'OSSF Scorecard score: 0' in result + assert 'Scorecard report: N/A' in result + + +def test_unmaintained_package_without_maintained_check(printer: TextPrinter, output: io.StringIO) -> None: + detection = _make_detection( + UNMAINTAINED_PACKAGE_POLICY_ID, + ossf={'score': 1.5, 'checks': [{'name': 'License', 'score': 10}]}, + ) + + result = _render(printer, output, detection) + + assert 'Maintained score: N/A' in result + assert 'OSSF Scorecard score: 1.5' in result + + +def test_license_compliance_still_prints_the_license(printer: TextPrinter, output: io.StringIO) -> None: + detection = _make_detection(LICENSE_COMPLIANCE_POLICY_ID, license='GPL-3.0') + + result = _render(printer, output, detection) + + assert 'License: GPL-3.0' in result + assert 'OSSF' not in result + + +def test_package_vulnerability_still_prints_the_patched_version(printer: TextPrinter, output: io.StringIO) -> None: + detection = _make_detection( + PACKAGE_VULNERABILITY_POLICY_ID, + alert={'first_patched_version': '4.17.21'}, + ) + + result = _render(printer, output, detection) + + assert 'First patched version: 4.17.21' in result + assert 'OSSF' not in result