From 5c0b99514e64e7866529cde3133a9c4bb2013040 Mon Sep 17 00:00:00 2001 From: Arad Traub Date: Tue, 25 Aug 2026 11:44:33 +0300 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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') From 9d85b2132884a0065759591653030b0a1d4c57ba Mon Sep 17 00:00:00 2001 From: Arad Traub Date: Sun, 30 Aug 2026 11:01:18 +0300 Subject: [PATCH 14/16] CM-71730: Read the maintained score only for the unmaintained policy The score was looked up for every SCA detection and pushed at a column that only the unmaintained table has, so vulnerability and license detections paid for a lookup that could never be rendered. Co-Authored-By: Claude Opus 5 --- cycode/cli/printers/tables/sca_table_printer.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index 34deba59..9992adc3 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -130,8 +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)) + if detection.detection_type_id == UNMAINTAINED_PACKAGE_POLICY_ID: + 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}[/]') From 992c17133031ee3aea771f17421b92d94877740a Mon Sep 17 00:00:00 2001 From: Arad Traub Date: Sun, 30 Aug 2026 11:31:23 +0300 Subject: [PATCH 15/16] CM-71730: Key SCA detail rows by policy instead of chaining conditions The printers picked their rows from an if/elif chain over has_alert, which is only a guess at the policy and has to grow a branch for every new one. It had already gone wrong once: unmaintained packages have no alert, so they fell to the license branch and rendered an empty License row. Each policy now contributes its own labelled fields from one map that both printers render, and a policy with no entry contributes nothing rather than borrowing another's fields. Co-Authored-By: Claude Opus 5 --- cycode/cli/printers/rich_printer.py | 16 +--- cycode/cli/printers/text_printer.py | 25 +----- .../cli/printers/utils/sca_policy_details.py | 52 ++++++++++++ .../printers/utils/test_sca_policy_details.py | 80 +++++++++++++++++++ 4 files changed, 137 insertions(+), 36 deletions(-) create mode 100644 cycode/cli/printers/utils/sca_policy_details.py create mode 100644 tests/cli/printers/utils/test_sca_policy_details.py diff --git a/cycode/cli/printers/rich_printer.py b/cycode/cli/printers/rich_printer.py index abbca49e..7969e8cb 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_maintained_score, get_ossf_report_url, get_ossf_score +from cycode.cli.printers.utils.sca_policy_details import get_sca_policy_details if TYPE_CHECKING: from cycode.cli.models import CliError, Detection, Document, LocalScanResult @@ -91,21 +91,11 @@ def __add_sca_scan_related_rows(details_table: Table, detection: 'Detection') -> details_table.add_row('Package', detection_details.get('package_name')) details_table.add_row('Version', detection_details.get('package_version')) - if detection.has_alert: - patched_version = detection_details['alert'].get('first_patched_version') - details_table.add_row('First patched version', patched_version or 'Not fixed') - dependency_path = detection_details.get('dependency_paths') 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: - details_table.add_row('License', detection_details.get('license')) + for label, value in get_sca_policy_details(detection): + details_table.add_row(label, value) @staticmethod def __add_iac_scan_related_rows(details_table: Table, detection: 'Detection') -> None: diff --git a/cycode/cli/printers/text_printer.py b/cycode/cli/printers/text_printer.py index 1397933a..fe0adabf 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_maintained_score, get_ossf_report_url, get_ossf_score +from cycode.cli.printers.utils.sca_policy_details import get_sca_policy_details if TYPE_CHECKING: from cycode.cli.models import Detection, LocalScanResult @@ -83,28 +83,7 @@ def __get_intermediate_summary_lines(self, detection: 'Detection') -> list[str]: @staticmethod 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: - patched_version = detection.detection_details['alert'].get('first_patched_version') - patched_version = patched_version or 'Not fixed' - - summary_lines.append(f'First patched version: [cyan]{patched_version}[/]\n') - else: - package_license = detection.detection_details.get('license', 'N/A') - summary_lines.append(f'License: [cyan]{package_license}[/]\n') - - return summary_lines + return [f'{label}: [cyan]{value}[/]\n' for label, value in get_sca_policy_details(detection)] def __print_detection_code_segment(self, detection: 'Detection', document: Document) -> None: self.console.print( diff --git a/cycode/cli/printers/utils/sca_policy_details.py b/cycode/cli/printers/utils/sca_policy_details.py new file mode 100644 index 00000000..2757a078 --- /dev/null +++ b/cycode/cli/printers/utils/sca_policy_details.py @@ -0,0 +1,52 @@ +from typing import TYPE_CHECKING, Callable + +from cycode.cli.consts import ( + LICENSE_COMPLIANCE_POLICY_ID, + PACKAGE_VULNERABILITY_POLICY_ID, + UNMAINTAINED_PACKAGE_POLICY_ID, +) +from cycode.cli.printers.utils.sca_ossf import get_maintained_score, get_ossf_report_url, get_ossf_score + +if TYPE_CHECKING: + from cycode.cyclient.models import Detection + +_NOT_AVAILABLE = 'N/A' + + +def _package_vulnerability_details(detection: 'Detection') -> list[tuple[str, str]]: + alert = detection.detection_details.get('alert') or {} + return [('First patched version', alert.get('first_patched_version') or 'Not fixed')] + + +def _license_compliance_details(detection: 'Detection') -> list[tuple[str, str]]: + return [('License', detection.detection_details.get('license') or _NOT_AVAILABLE)] + + +def _unmaintained_package_details(detection: 'Detection') -> list[tuple[str, str]]: + detection_details = detection.detection_details + maintained_score = get_maintained_score(detection_details) + ossf_score = get_ossf_score(detection_details) + + return [ + ('Maintained score', _NOT_AVAILABLE if maintained_score is None else str(maintained_score)), + ('OSSF Scorecard score', _NOT_AVAILABLE if ossf_score is None else str(ossf_score)), + ('Scorecard report', get_ossf_report_url(detection_details) or _NOT_AVAILABLE), + ] + + +_DETAILS_BY_POLICY: dict[str, Callable[['Detection'], list[tuple[str, str]]]] = { + PACKAGE_VULNERABILITY_POLICY_ID: _package_vulnerability_details, + LICENSE_COMPLIANCE_POLICY_ID: _license_compliance_details, + UNMAINTAINED_PACKAGE_POLICY_ID: _unmaintained_package_details, +} + + +def get_sca_policy_details(detection: 'Detection') -> list[tuple[str, str]]: + """Labelled fields specific to the SCA policy that raised the detection, in display order. + + A policy with no entry contributes nothing rather than borrowing another policy's fields, so a new one shows + no details until it is added here. + """ + build_details = _DETAILS_BY_POLICY.get(detection.detection_type_id) + + return build_details(detection) if build_details else [] diff --git a/tests/cli/printers/utils/test_sca_policy_details.py b/tests/cli/printers/utils/test_sca_policy_details.py new file mode 100644 index 00000000..84d4f861 --- /dev/null +++ b/tests/cli/printers/utils/test_sca_policy_details.py @@ -0,0 +1,80 @@ +from cycode.cli.consts import ( + LICENSE_COMPLIANCE_POLICY_ID, + PACKAGE_VULNERABILITY_POLICY_ID, + UNMAINTAINED_PACKAGE_POLICY_ID, +) +from cycode.cli.printers.utils.sca_policy_details import get_sca_policy_details +from cycode.cyclient.models import Detection + + +def _make_detection(policy_id: str, **details: object) -> Detection: + return Detection( + detection_type_id=policy_id, + type='sca', + message='message', + detection_details=dict(details), + detection_rule_id='rule-id', + severity='Medium', + ) + + +def test_package_vulnerability_reports_the_patched_version() -> None: + detection = _make_detection(PACKAGE_VULNERABILITY_POLICY_ID, alert={'first_patched_version': '4.17.21'}) + + assert get_sca_policy_details(detection) == [('First patched version', '4.17.21')] + + +def test_package_vulnerability_without_a_patch() -> None: + detection = _make_detection(PACKAGE_VULNERABILITY_POLICY_ID, alert={'first_patched_version': None}) + + assert get_sca_policy_details(detection) == [('First patched version', 'Not fixed')] + + +def test_license_compliance_reports_the_license() -> None: + detection = _make_detection(LICENSE_COMPLIANCE_POLICY_ID, license='GPL-3.0') + + assert get_sca_policy_details(detection) == [('License', 'GPL-3.0')] + + +def test_license_compliance_without_a_license() -> None: + detection = _make_detection(LICENSE_COMPLIANCE_POLICY_ID) + + assert get_sca_policy_details(detection) == [('License', 'N/A')] + + +def test_unmaintained_package_reports_the_maintained_check_first() -> 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}], + }, + ) + + assert get_sca_policy_details(detection) == [ + ('Maintained score', '0'), + ('OSSF Scorecard score', '4.1'), + ('Scorecard report', 'https://scorecard.dev/viewer/?uri=github.com/a/b'), + ] + + +def test_unmaintained_package_without_a_scorecard() -> None: + detection = _make_detection(UNMAINTAINED_PACKAGE_POLICY_ID) + + assert get_sca_policy_details(detection) == [ + ('Maintained score', 'N/A'), + ('OSSF Scorecard score', 'N/A'), + ('Scorecard report', 'N/A'), + ] + + +def test_an_unregistered_policy_contributes_nothing() -> None: + """A policy with no entry must stay silent rather than borrow another policy's fields. + + Before this was keyed by policy, an unmaintained detection fell through to the license branch and rendered + an empty License row. A fourth policy would do the same. + """ + detection = _make_detection('00000000-0000-0000-0000-000000000000', license='GPL-3.0', alert={}) + + assert get_sca_policy_details(detection) == [] From 6e2e9b11bcec39c4d3357b453a252db6025facf8 Mon Sep 17 00:00:00 2001 From: Arad Traub Date: Sun, 30 Aug 2026 17:06:30 +0300 Subject: [PATCH 16/16] Show the CVE row only for package vulnerabilities The rich printer added a CVEs row to every SCA detection, so unmaintained and license findings - neither of which carries a vulnerability_id - rendered an empty row. The CVE now comes from the policy-keyed details alongside the first patched version, so a policy that has no CVE simply does not show the field. Co-Authored-By: Claude Opus 5 --- cycode/cli/printers/rich_printer.py | 1 - .../cli/printers/utils/sca_policy_details.py | 7 +++- .../printers/utils/test_sca_policy_details.py | 33 ++++++++++++++++--- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/cycode/cli/printers/rich_printer.py b/cycode/cli/printers/rich_printer.py index 7969e8cb..f326f166 100644 --- a/cycode/cli/printers/rich_printer.py +++ b/cycode/cli/printers/rich_printer.py @@ -87,7 +87,6 @@ def __add_secret_scan_related_rows(details_table: Table, detection: 'Detection') def __add_sca_scan_related_rows(details_table: Table, detection: 'Detection') -> None: detection_details = detection.detection_details - details_table.add_row('CVEs', get_detection_clickable_cwe_cve(consts.SCA_SCAN_TYPE, detection)) details_table.add_row('Package', detection_details.get('package_name')) details_table.add_row('Version', detection_details.get('package_version')) diff --git a/cycode/cli/printers/utils/sca_policy_details.py b/cycode/cli/printers/utils/sca_policy_details.py index 2757a078..757c1be0 100644 --- a/cycode/cli/printers/utils/sca_policy_details.py +++ b/cycode/cli/printers/utils/sca_policy_details.py @@ -3,8 +3,10 @@ from cycode.cli.consts import ( LICENSE_COMPLIANCE_POLICY_ID, PACKAGE_VULNERABILITY_POLICY_ID, + SCA_SCAN_TYPE, UNMAINTAINED_PACKAGE_POLICY_ID, ) +from cycode.cli.printers.utils.detection_data import get_detection_clickable_cwe_cve from cycode.cli.printers.utils.sca_ossf import get_maintained_score, get_ossf_report_url, get_ossf_score if TYPE_CHECKING: @@ -15,7 +17,10 @@ def _package_vulnerability_details(detection: 'Detection') -> list[tuple[str, str]]: alert = detection.detection_details.get('alert') or {} - return [('First patched version', alert.get('first_patched_version') or 'Not fixed')] + return [ + ('CVEs', get_detection_clickable_cwe_cve(SCA_SCAN_TYPE, detection) or _NOT_AVAILABLE), + ('First patched version', alert.get('first_patched_version') or 'Not fixed'), + ] def _license_compliance_details(detection: 'Detection') -> list[tuple[str, str]]: diff --git a/tests/cli/printers/utils/test_sca_policy_details.py b/tests/cli/printers/utils/test_sca_policy_details.py index 84d4f861..83b7ddbd 100644 --- a/tests/cli/printers/utils/test_sca_policy_details.py +++ b/tests/cli/printers/utils/test_sca_policy_details.py @@ -18,16 +18,26 @@ def _make_detection(policy_id: str, **details: object) -> Detection: ) -def test_package_vulnerability_reports_the_patched_version() -> None: - detection = _make_detection(PACKAGE_VULNERABILITY_POLICY_ID, alert={'first_patched_version': '4.17.21'}) +def test_package_vulnerability_reports_the_cve_and_the_patched_version() -> None: + detection = _make_detection( + PACKAGE_VULNERABILITY_POLICY_ID, + alert={'first_patched_version': '4.17.21'}, + vulnerability_id='CVE-2021-23337', + ) - assert get_sca_policy_details(detection) == [('First patched version', '4.17.21')] + assert get_sca_policy_details(detection) == [ + ('CVEs', '[link=https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2021-23337]CVE-2021-23337[/]'), + ('First patched version', '4.17.21'), + ] -def test_package_vulnerability_without_a_patch() -> None: +def test_package_vulnerability_without_a_patch_or_a_cve() -> None: detection = _make_detection(PACKAGE_VULNERABILITY_POLICY_ID, alert={'first_patched_version': None}) - assert get_sca_policy_details(detection) == [('First patched version', 'Not fixed')] + assert get_sca_policy_details(detection) == [ + ('CVEs', 'N/A'), + ('First patched version', 'Not fixed'), + ] def test_license_compliance_reports_the_license() -> None: @@ -69,6 +79,19 @@ def test_unmaintained_package_without_a_scorecard() -> None: ] +def test_only_package_vulnerability_reports_a_cve() -> None: + """A CVE belongs to the vulnerability policy alone. + + It used to be rendered for every SCA detection, so an unmaintained or license finding - neither of which + carries a vulnerability_id - showed an empty CVEs row. + """ + for policy_id in (LICENSE_COMPLIANCE_POLICY_ID, UNMAINTAINED_PACKAGE_POLICY_ID): + detection = _make_detection(policy_id, vulnerability_id='CVE-2021-23337') + + labels = [label for label, _ in get_sca_policy_details(detection)] + assert 'CVEs' not in labels + + def test_an_unregistered_policy_contributes_nothing() -> None: """A policy with no entry must stay silent rather than borrow another policy's fields.