From 5c0b99514e64e7866529cde3133a9c4bb2013040 Mon Sep 17 00:00:00 2001 From: Arad Traub Date: Tue, 25 Aug 2026 11:44:33 +0300 Subject: [PATCH 01/13] CM-68446: Add the unmaintained-packages SCA scan option --- cycode/cli/apps/scan/scan_command.py | 6 ++++- cycode/cli/apps/scan/scan_parameters.py | 1 + cycode/cli/cli_types.py | 1 + .../cli/commands/scan/test_scan_parameters.py | 23 +++++++++++++++++++ 4 files changed, 30 insertions(+), 1 deletion(-) 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..4c669c24 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'), '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/tests/cli/commands/scan/test_scan_parameters.py b/tests/cli/commands/scan/test_scan_parameters.py index 6933e9bc..a91c6dcf 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,24 @@ 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_missing_from_context(mock_context: MagicMock) -> None: + """Test that maintainability is None when the option was not selected by the user.""" + mock_context.obj.pop('unmaintained-packages') + + params = _get_default_scan_parameters(mock_context) + + assert params['maintainability'] is None From 79c620c7e52359c3d6626352f58d7e2b2d36b5f6 Mon Sep 17 00:00:00 2001 From: Arad Traub Date: Tue, 25 Aug 2026 11:44:35 +0300 Subject: [PATCH 02/13] CM-68446: Render unmaintained package detections in the SCA printers --- cycode/cli/consts.py | 1 + cycode/cli/printers/rich_printer.py | 10 +- .../cli/printers/tables/sca_table_printer.py | 14 ++- tests/cli/printers/test_sca_table_printer.py | 109 ++++++++++++++++++ 4 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 tests/cli/printers/test_sca_table_printer.py 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..8e542673 100644 --- a/cycode/cli/printers/rich_printer.py +++ b/cycode/cli/printers/rich_printer.py @@ -97,7 +97,15 @@ 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: + ossf_scorecard_score = detection_details.get('ossf_scorecard_score') + details_table.add_row( + 'OSSF Scorecard score', 'N/A' if ossf_scorecard_score is None else str(ossf_scorecard_score) + ) + details_table.add_row( + 'Source code repository', detection_details.get('source_code_repository_url') 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..50485b7c 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -2,7 +2,11 @@ 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 @@ -23,6 +27,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) +OSSF_SCORE_COLUMN = column_builder.build(name='OSSF Score') 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 +56,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 +69,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(OSSF_SCORE_COLUMN) if is_git_diff_based_scan(self.command_scan_type): table.add_column(REPOSITORY_COLUMN) @@ -120,6 +129,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')) + ossf_scorecard_score = detection_details.get('ossf_scorecard_score') + table.add_cell(OSSF_SCORE_COLUMN, 'N/A' if ossf_scorecard_score is None else str(ossf_scorecard_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/tests/cli/printers/test_sca_table_printer.py b/tests/cli/printers/test_sca_table_printer.py new file mode 100644 index 00000000..b8b65766 --- /dev/null +++ b/tests/cli/printers/test_sca_table_printer.py @@ -0,0 +1,109 @@ +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, + OSSF_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 OSSF_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', + 'OSSF Score', + 'Dependency Paths', + 'Direct Dependency', + 'Development Dependency', + ] + + +def test_get_table_other_policies_do_not_get_the_score_column(printer: ScaTablePrinter) -> None: + assert OSSF_SCORE_COLUMN not in printer._get_table(PACKAGE_VULNERABILITY_POLICY_ID).get_columns_info() + assert OSSF_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_scorecard_score=1.5, + source_code_repository_url='https://github.com/example/left-pad', + ) + + ScaTablePrinter._enrich_table_with_values(table, detection) + + row = table.get_rows()[0] + score_index = table.get_columns_info().index(OSSF_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(OSSF_SCORE_COLUMN) + assert row[score_index] == 'N/A' From 0f6832328d04856b8ef266f2be4b077a86ff336f Mon Sep 17 00:00:00 2001 From: Arad Traub Date: Tue, 25 Aug 2026 11:44:35 +0300 Subject: [PATCH 03/13] CM-68446: Document the unmaintained-packages SCA scan option --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 627f2c31..9248acaa 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,17 @@ 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) score is low), add the argument `--sca-scan unmaintained-packages` following the `-t sca` or `--scan-type sca` option. + +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] From 20fa8eebe5a20ff4a16461cfaee44ded41cbea14 Mon Sep 17 00:00:00 2001 From: Arad Traub Date: Tue, 25 Aug 2026 11:46:37 +0300 Subject: [PATCH 04/13] CM-68446: Stop rich from highlighting the OSSF score column --- cycode/cli/printers/tables/sca_table_printer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index 50485b7c..ca756145 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -27,7 +27,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) -OSSF_SCORE_COLUMN = column_builder.build(name='OSSF Score') +OSSF_SCORE_COLUMN = column_builder.build(name='OSSF 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) From e65887bcf38daadec31338c0a8bcdd9310af34fe Mon Sep 17 00:00:00 2001 From: Arad Traub Date: Tue, 25 Aug 2026 14:56:26 +0300 Subject: [PATCH 05/13] CM-68446: Read package health from the nested ossf detection detail --- cycode/cli/printers/rich_printer.py | 11 ++++------- cycode/cli/printers/tables/sca_table_printer.py | 5 +++-- cycode/cli/printers/utils/sca_ossf.py | 14 ++++++++++++++ tests/cli/printers/test_sca_table_printer.py | 3 +-- 4 files changed, 22 insertions(+), 11 deletions(-) create mode 100644 cycode/cli/printers/utils/sca_ossf.py diff --git a/cycode/cli/printers/rich_printer.py b/cycode/cli/printers/rich_printer.py index 8e542673..39e836b4 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_ossf_report_url, get_ossf_score if TYPE_CHECKING: from cycode.cli.models import CliError, Detection, Document, LocalScanResult @@ -98,13 +99,9 @@ def __add_sca_scan_related_rows(details_table: Table, detection: 'Detection') -> details_table.add_row('Dependency path', dependency_path or 'N/A') if detection.detection_type_id == consts.UNMAINTAINED_PACKAGE_POLICY_ID: - ossf_scorecard_score = detection_details.get('ossf_scorecard_score') - details_table.add_row( - 'OSSF Scorecard score', 'N/A' if ossf_scorecard_score is None else str(ossf_scorecard_score) - ) - details_table.add_row( - 'Source code repository', detection_details.get('source_code_repository_url') or 'N/A' - ) + ossf_score = get_ossf_score(detection_details) + 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')) diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index ca756145..23365941 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -13,6 +13,7 @@ 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_ossf_score from cycode.cli.utils.string_utils import shortcut_dependency_paths if TYPE_CHECKING: @@ -129,8 +130,8 @@ 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')) - ossf_scorecard_score = detection_details.get('ossf_scorecard_score') - table.add_cell(OSSF_SCORE_COLUMN, 'N/A' if ossf_scorecard_score is None else str(ossf_scorecard_score)) + ossf_score = get_ossf_score(detection_details) + table.add_cell(OSSF_SCORE_COLUMN, 'N/A' if ossf_score is None else str(ossf_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/utils/sca_ossf.py b/cycode/cli/printers/utils/sca_ossf.py new file mode 100644 index 00000000..71da0e10 --- /dev/null +++ b/cycode/cli/printers/utils/sca_ossf.py @@ -0,0 +1,14 @@ +from typing import Any, Optional + + +def _get_ossf_details(detection_details: dict) -> dict: + """Package health lives in a nested "ossf" object, absent when no scorecard was resolved.""" + 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') diff --git a/tests/cli/printers/test_sca_table_printer.py b/tests/cli/printers/test_sca_table_printer.py index b8b65766..627d934b 100644 --- a/tests/cli/printers/test_sca_table_printer.py +++ b/tests/cli/printers/test_sca_table_printer.py @@ -87,8 +87,7 @@ def test_enrich_table_with_values_populates_the_score(printer: ScaTablePrinter) ecosystem='npm', package_name='left-pad', package_version='1.0.0', - ossf_scorecard_score=1.5, - source_code_repository_url='https://github.com/example/left-pad', + ossf={'score': 1.5, 'scorecard_report_url': 'https://scorecard.dev/viewer/?uri=github.com/example/left-pad'}, ) ScaTablePrinter._enrich_table_with_values(table, detection) From beb6a6213daec83afb47ca29b802b8cce3fef2f7 Mon Sep 17 00:00:00 2001 From: Arad Traub Date: Tue, 25 Aug 2026 16:47:58 +0300 Subject: [PATCH 06/13] Revert "CM-68446: Document the unmaintained-packages SCA scan option" This reverts commit 0f6832328d04856b8ef266f2be4b077a86ff336f. --- README.md | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/README.md b/README.md index 9248acaa..627f2c31 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`/`unmaintained-packages`). The default is all. | +| `--sca-scan` | Specify the SCA scan you wish to execute (`package-vulnerabilities`/`license-compliance`). The default is both. | | `--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,17 +867,6 @@ 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) score is low), add the argument `--sca-scan unmaintained-packages` following the `-t sca` or `--scan-type sca` option. - -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] From c21ed68349abdd0a6df4b3772bde7d9763321158 Mon Sep 17 00:00:00 2001 From: Arad Traub Date: Tue, 25 Aug 2026 16:47:59 +0300 Subject: [PATCH 07/13] Revert "CM-68446: Add the unmaintained-packages SCA scan option" This reverts commit 5c0b99514e64e7866529cde3133a9c4bb2013040. --- cycode/cli/apps/scan/scan_command.py | 6 +---- cycode/cli/apps/scan/scan_parameters.py | 1 - cycode/cli/cli_types.py | 1 - .../cli/commands/scan/test_scan_parameters.py | 23 ------------------- 4 files changed, 1 insertion(+), 30 deletions(-) diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 48c425b9..427f2d78 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -93,11 +93,7 @@ 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.UNMAINTAINED_PACKAGES, - ), + ] = (ScaScanTypeOption.PACKAGE_VULNERABILITIES, ScaScanTypeOption.LICENSE_COMPLIANCE), monitor: Annotated[ bool, typer.Option( diff --git a/cycode/cli/apps/scan/scan_parameters.py b/cycode/cli/apps/scan/scan_parameters.py index 4c669c24..f362d419 100644 --- a/cycode/cli/apps/scan/scan_parameters.py +++ b/cycode/cli/apps/scan/scan_parameters.py @@ -16,7 +16,6 @@ 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'), '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 8b68d7af..ed277cc6 100644 --- a/cycode/cli/cli_types.py +++ b/cycode/cli/cli_types.py @@ -40,7 +40,6 @@ 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/tests/cli/commands/scan/test_scan_parameters.py b/tests/cli/commands/scan/test_scan_parameters.py index a91c6dcf..6933e9bc 100644 --- a/tests/cli/commands/scan/test_scan_parameters.py +++ b/tests/cli/commands/scan/test_scan_parameters.py @@ -14,7 +14,6 @@ def mock_context() -> MagicMock: 'report': False, 'package-vulnerabilities': True, 'license-compliance': True, - 'unmaintained-packages': True, } ctx.info_name = 'test-command' return ctx @@ -28,7 +27,6 @@ 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 @@ -115,24 +113,3 @@ 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_missing_from_context(mock_context: MagicMock) -> None: - """Test that maintainability is None when the option was not selected by the user.""" - mock_context.obj.pop('unmaintained-packages') - - params = _get_default_scan_parameters(mock_context) - - assert params['maintainability'] is None From 7b3fad8934d8abc11469c750ec69b173606473de Mon Sep 17 00:00:00 2001 From: Arad Traub Date: Tue, 25 Aug 2026 16:58:31 +0300 Subject: [PATCH 08/13] Reapply "CM-68446: Add the unmaintained-packages SCA scan option" This reverts commit c21ed68349abdd0a6df4b3772bde7d9763321158. --- cycode/cli/apps/scan/scan_command.py | 6 ++++- cycode/cli/apps/scan/scan_parameters.py | 1 + cycode/cli/cli_types.py | 1 + .../cli/commands/scan/test_scan_parameters.py | 23 +++++++++++++++++++ 4 files changed, 30 insertions(+), 1 deletion(-) 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..4c669c24 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'), '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/tests/cli/commands/scan/test_scan_parameters.py b/tests/cli/commands/scan/test_scan_parameters.py index 6933e9bc..a91c6dcf 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,24 @@ 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_missing_from_context(mock_context: MagicMock) -> None: + """Test that maintainability is None when the option was not selected by the user.""" + mock_context.obj.pop('unmaintained-packages') + + params = _get_default_scan_parameters(mock_context) + + assert params['maintainability'] is None From 973b7a45711cd4793955b23f4ba441542c485496 Mon Sep 17 00:00:00 2001 From: Arad Traub Date: Tue, 25 Aug 2026 16:58:31 +0300 Subject: [PATCH 09/13] Reapply "CM-68446: Document the unmaintained-packages SCA scan option" This reverts commit beb6a6213daec83afb47ca29b802b8cce3fef2f7. --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 627f2c31..9248acaa 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,17 @@ 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) score is low), add the argument `--sca-scan unmaintained-packages` following the `-t sca` or `--scan-type sca` option. + +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] From cdcb03e920b445ee9f859fa35c5b7d155efddc60 Mon Sep 17 00:00:00 2001 From: Arad Traub Date: Tue, 25 Aug 2026 16:59:23 +0300 Subject: [PATCH 10/13] CM-68446: Send the maintainability option as an explicit result filter --- README.md | 3 +++ cycode/cli/apps/scan/scan_parameters.py | 4 +++- tests/cli/commands/scan/test_scan_parameters.py | 12 +++++++++--- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 9248acaa..0fca54f5 100644 --- a/README.md +++ b/README.md @@ -874,6 +874,9 @@ In the previous example, if you wanted to only scan a branch named `dev`, you co To scan only for unmaintained packages (packages whose [OpenSSF Scorecard](https://scorecard.dev) score is low), 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` diff --git a/cycode/cli/apps/scan/scan_parameters.py b/cycode/cli/apps/scan/scan_parameters.py index 4c669c24..1f2cebca 100644 --- a/cycode/cli/apps/scan/scan_parameters.py +++ b/cycode/cli/apps/scan/scan_parameters.py @@ -16,7 +16,9 @@ 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'), + # A filter, not a switch: an explicit False is what excludes the policy from the result. Absence would + # read as "no opinion" server-side, which is right for older CLIs but wrong for a narrowed selection. + '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/tests/cli/commands/scan/test_scan_parameters.py b/tests/cli/commands/scan/test_scan_parameters.py index a91c6dcf..a0286e31 100644 --- a/tests/cli/commands/scan/test_scan_parameters.py +++ b/tests/cli/commands/scan/test_scan_parameters.py @@ -129,10 +129,16 @@ def test_get_default_scan_parameters_maintainability_uses_unmaintained_packages_ assert 'unmaintained_packages' not in params -def test_get_default_scan_parameters_maintainability_missing_from_context(mock_context: MagicMock) -> None: - """Test that maintainability is None when the option was not selected by the user.""" +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 None + assert params['maintainability'] is False From 6c3788d3109ec5600d7b5768ae52979541cf9831 Mon Sep 17 00:00:00 2001 From: Arad Traub Date: Tue, 25 Aug 2026 17:07:29 +0300 Subject: [PATCH 11/13] CM-68446: Drop explanatory comments from the scan parameter and ossf helper --- cycode/cli/apps/scan/scan_parameters.py | 2 -- cycode/cli/printers/utils/sca_ossf.py | 1 - 2 files changed, 3 deletions(-) diff --git a/cycode/cli/apps/scan/scan_parameters.py b/cycode/cli/apps/scan/scan_parameters.py index 1f2cebca..d297010a 100644 --- a/cycode/cli/apps/scan/scan_parameters.py +++ b/cycode/cli/apps/scan/scan_parameters.py @@ -16,8 +16,6 @@ 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'), - # A filter, not a switch: an explicit False is what excludes the policy from the result. Absence would - # read as "no opinion" server-side, which is right for older CLIs but wrong for a narrowed selection. 'maintainability': ctx.obj.get('unmaintained-packages', False), 'command_type': ctx.info_name.replace('-', '_'), # save backward compatibility 'aggregation_id': str(generate_unique_scan_id()), diff --git a/cycode/cli/printers/utils/sca_ossf.py b/cycode/cli/printers/utils/sca_ossf.py index 71da0e10..ec322108 100644 --- a/cycode/cli/printers/utils/sca_ossf.py +++ b/cycode/cli/printers/utils/sca_ossf.py @@ -2,7 +2,6 @@ def _get_ossf_details(detection_details: dict) -> dict: - """Package health lives in a nested "ossf" object, absent when no scorecard was resolved.""" return detection_details.get('ossf') or {} From b5e57bc75f3177c0afb6dd520f3ee8470074ba39 Mon Sep 17 00:00:00 2001 From: Arad Traub Date: Thu, 27 Aug 2026 12:03:51 +0300 Subject: [PATCH 12/13] CM-68446: Render the OSSF score in the text printer The text printer fell through to the license branch for unmaintained package detections, printing a License row instead of the score. Co-Authored-By: Claude Opus 5 --- cycode/cli/printers/text_printer.py | 10 ++- tests/cli/printers/test_text_printer.py | 107 ++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 tests/cli/printers/test_text_printer.py diff --git a/cycode/cli/printers/text_printer.py b/cycode/cli/printers/text_printer.py index 51da53c5..227506ea 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_ossf_report_url, get_ossf_score if TYPE_CHECKING: from cycode.cli.models import Detection, LocalScanResult @@ -84,7 +85,14 @@ 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: + ossf_score = get_ossf_score(detection.detection_details) + 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'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/tests/cli/printers/test_text_printer.py b/tests/cli/printers/test_text_printer.py new file mode 100644 index 00000000..927757e2 --- /dev/null +++ b/tests/cli/printers/test_text_printer.py @@ -0,0 +1,107 @@ +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': 2.1, 'scorecard_report_url': 'https://scorecard.dev/viewer/?uri=github.com/a/b'}, + ) + + result = _render(printer, output, detection) + + assert 'OSSF Scorecard score: 2.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 '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': ''}) + + result = _render(printer, output, detection) + + assert 'OSSF Scorecard score: 0' in result + assert 'Scorecard report: N/A' 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 From 2284682810e0c4e1bd6b1bfc772b1a3bfa40e39e Mon Sep 17 00:00:00 2001 From: Arad Traub Date: Thu, 27 Aug 2026 13:43:20 +0300 Subject: [PATCH 13/13] CM-71730: Report the OSSF Maintained check score, not the aggregate The unmaintained policy decides on the scorecard's Maintained check, so showing the aggregate as the only number was misleading: a package flagged as unmaintained could display a healthy-looking 4.1. The Maintained score now leads and the aggregate stays as context. Co-Authored-By: Claude Opus 5 --- README.md | 2 +- cycode/cli/printers/rich_printer.py | 4 ++- .../cli/printers/tables/sca_table_printer.py | 10 +++---- cycode/cli/printers/text_printer.py | 5 +++- cycode/cli/printers/utils/sca_ossf.py | 10 +++++++ tests/cli/printers/test_sca_table_printer.py | 20 +++++++------ tests/cli/printers/test_text_printer.py | 28 +++++++++++++++++-- 7 files changed, 60 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 0fca54f5..da428ec8 100644 --- a/README.md +++ b/README.md @@ -872,7 +872,7 @@ In the previous example, if you wanted to only scan a branch named `dev`, you co > [!NOTE] > This option is only available to SCA scans. -To scan only for unmaintained packages (packages whose [OpenSSF Scorecard](https://scorecard.dev) score is low), add the argument `--sca-scan unmaintained-packages` following the `-t sca` or `--scan-type sca` option. +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. diff --git a/cycode/cli/printers/rich_printer.py b/cycode/cli/printers/rich_printer.py index 39e836b4..abbca49e 100644 --- a/cycode/cli/printers/rich_printer.py +++ b/cycode/cli/printers/rich_printer.py @@ -16,7 +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_ossf_report_url, get_ossf_score +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 @@ -99,7 +99,9 @@ def __add_sca_scan_related_rows(details_table: Table, detection: 'Detection') -> details_table.add_row('Dependency path', dependency_path or 'N/A') 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: diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index 23365941..34deba59 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -13,7 +13,7 @@ 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_ossf_score +from cycode.cli.printers.utils.sca_ossf import get_maintained_score from cycode.cli.utils.string_utils import shortcut_dependency_paths if TYPE_CHECKING: @@ -28,7 +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) -OSSF_SCORE_COLUMN = column_builder.build(name='OSSF Score', 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) @@ -71,7 +71,7 @@ def _get_table(self, policy_id: str) -> Table: elif policy_id == LICENSE_COMPLIANCE_POLICY_ID: table.add_column(LICENSE_COLUMN) elif policy_id == UNMAINTAINED_PACKAGE_POLICY_ID: - table.add_column(OSSF_SCORE_COLUMN) + table.add_column(MAINTAINED_SCORE_COLUMN) if is_git_diff_based_scan(self.command_scan_type): table.add_column(REPOSITORY_COLUMN) @@ -130,8 +130,8 @@ 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')) - ossf_score = get_ossf_score(detection_details) - table.add_cell(OSSF_SCORE_COLUMN, 'N/A' if ossf_score is None else str(ossf_score)) + 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 227506ea..1397933a 100644 --- a/cycode/cli/printers/text_printer.py +++ b/cycode/cli/printers/text_printer.py @@ -7,7 +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_ossf_report_url, get_ossf_score +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 @@ -86,10 +86,13 @@ def __get_sca_related_summary_lines(detection: 'Detection') -> list[str]: summary_lines = [] 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: diff --git a/cycode/cli/printers/utils/sca_ossf.py b/cycode/cli/printers/utils/sca_ossf.py index ec322108..11d3f662 100644 --- a/cycode/cli/printers/utils/sca_ossf.py +++ b/cycode/cli/printers/utils/sca_ossf.py @@ -1,5 +1,7 @@ from typing import Any, Optional +_MAINTAINED_CHECK_NAME = 'maintained' + def _get_ossf_details(detection_details: dict) -> dict: return detection_details.get('ossf') or {} @@ -11,3 +13,11 @@ def get_ossf_score(detection_details: dict) -> Optional[Any]: 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/printers/test_sca_table_printer.py b/tests/cli/printers/test_sca_table_printer.py index 627d934b..37fbfdba 100644 --- a/tests/cli/printers/test_sca_table_printer.py +++ b/tests/cli/printers/test_sca_table_printer.py @@ -11,7 +11,7 @@ from cycode.cli.printers.tables.sca_table_printer import ( CVE_COLUMNS, LICENSE_COLUMN, - OSSF_SCORE_COLUMN, + MAINTAINED_SCORE_COLUMN, UPGRADE_COLUMN, ScaTablePrinter, ) @@ -53,7 +53,7 @@ def test_get_title_unknown_policy() -> None: def test_get_table_unmaintained_packages_columns(printer: ScaTablePrinter) -> None: columns = printer._get_table(UNMAINTAINED_PACKAGE_POLICY_ID).get_columns_info() - assert OSSF_SCORE_COLUMN in columns + assert MAINTAINED_SCORE_COLUMN in columns assert CVE_COLUMNS not in columns assert UPGRADE_COLUMN not in columns assert LICENSE_COLUMN not in columns @@ -67,7 +67,7 @@ def test_get_table_unmaintained_packages_column_order(printer: ScaTablePrinter) 'Code Project', 'Ecosystem', 'Package', - 'OSSF Score', + 'Maintained Score', 'Dependency Paths', 'Direct Dependency', 'Development Dependency', @@ -75,8 +75,8 @@ def test_get_table_unmaintained_packages_column_order(printer: ScaTablePrinter) def test_get_table_other_policies_do_not_get_the_score_column(printer: ScaTablePrinter) -> None: - assert OSSF_SCORE_COLUMN not in printer._get_table(PACKAGE_VULNERABILITY_POLICY_ID).get_columns_info() - assert OSSF_SCORE_COLUMN not in printer._get_table(LICENSE_COMPLIANCE_POLICY_ID).get_columns_info() + 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: @@ -87,13 +87,17 @@ def test_enrich_table_with_values_populates_the_score(printer: ScaTablePrinter) ecosystem='npm', package_name='left-pad', package_version='1.0.0', - ossf={'score': 1.5, 'scorecard_report_url': 'https://scorecard.dev/viewer/?uri=github.com/example/left-pad'}, + 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(OSSF_SCORE_COLUMN) + score_index = table.get_columns_info().index(MAINTAINED_SCORE_COLUMN) assert row[score_index] == '1.5' @@ -104,5 +108,5 @@ def test_enrich_table_with_values_missing_score(printer: ScaTablePrinter) -> Non ScaTablePrinter._enrich_table_with_values(table, detection) row = table.get_rows()[0] - score_index = table.get_columns_info().index(OSSF_SCORE_COLUMN) + 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 index 927757e2..f973ca93 100644 --- a/tests/cli/printers/test_text_printer.py +++ b/tests/cli/printers/test_text_printer.py @@ -58,12 +58,17 @@ def _render(printer: TextPrinter, output: io.StringIO, detection: Detection) -> def test_unmaintained_package_prints_the_score_and_report(printer: TextPrinter, output: io.StringIO) -> None: detection = _make_detection( UNMAINTAINED_PACKAGE_POLICY_ID, - ossf={'score': 2.1, 'scorecard_report_url': 'https://scorecard.dev/viewer/?uri=github.com/a/b'}, + 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 'OSSF Scorecard score: 2.1' in result + 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 @@ -73,19 +78,36 @@ def test_unmaintained_package_without_ossf_details(printer: TextPrinter, output: 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': ''}) + 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')