Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
5c0b995
CM-68446: Add the unmaintained-packages SCA scan option
AradTraub Aug 25, 2026
79c620c
CM-68446: Render unmaintained package detections in the SCA printers
AradTraub Aug 25, 2026
0f68323
CM-68446: Document the unmaintained-packages SCA scan option
AradTraub Aug 25, 2026
20fa8ee
CM-68446: Stop rich from highlighting the OSSF score column
AradTraub Aug 25, 2026
e65887b
CM-68446: Read package health from the nested ossf detection detail
AradTraub Aug 25, 2026
beb6a62
Revert "CM-68446: Document the unmaintained-packages SCA scan option"
AradTraub Aug 25, 2026
c21ed68
Revert "CM-68446: Add the unmaintained-packages SCA scan option"
AradTraub Aug 25, 2026
7b3fad8
Reapply "CM-68446: Add the unmaintained-packages SCA scan option"
AradTraub Aug 25, 2026
973b7a4
Reapply "CM-68446: Document the unmaintained-packages SCA scan option"
AradTraub Aug 25, 2026
cdcb03e
CM-68446: Send the maintainability option as an explicit result filter
AradTraub Aug 25, 2026
6c3788d
CM-68446: Drop explanatory comments from the scan parameter and ossf …
AradTraub Aug 25, 2026
b5e57bc
CM-68446: Render the OSSF score in the text printer
AradTraub Aug 27, 2026
2284682
CM-71730: Report the OSSF Maintained check score, not the aggregate
AradTraub Aug 27, 2026
9d85b21
CM-71730: Read the maintained score only for the unmaintained policy
AradTraub Aug 30, 2026
992c171
CM-71730: Key SCA detail rows by policy instead of chaining conditions
AradTraub Aug 30, 2026
6e2e9b1
Show the CVE row only for package vulnerabilities
AradTraub Aug 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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! |
Expand Down Expand Up @@ -867,6 +867,20 @@ In the previous example, if you wanted to only scan a branch named `dev`, you co

`cycode scan -t sca --sca-scan license-compliance repository ~/home/git/codebase -b dev`

#### Unmaintained Packages Option

> [!NOTE]
> This option is only available to SCA scans.

To scan only for unmaintained packages (packages whose [OpenSSF Scorecard](https://scorecard.dev) `Maintained` check is low, meaning little or no recent commit and issue activity), add the argument `--sca-scan unmaintained-packages` following the `-t sca` or `--scan-type sca` option.

> [!NOTE]
> Whether unmaintained packages are reported at all is controlled by your organization's policy. This option narrows what a scan reports; it cannot enable a policy that is turned off for your tenant.

In the previous example, if you wanted to only run an SCA scan on unmaintained packages, you could execute the following:

`cycode scan -t sca --sca-scan unmaintained-packages repository ~/home/git/codebase`

#### Lock Restore Option

> [!NOTE]
Expand Down
6 changes: 5 additions & 1 deletion cycode/cli/apps/scan/scan_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions cycode/cli/apps/scan/scan_parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def _get_default_scan_parameters(ctx: typer.Context) -> dict:
'report': ctx.obj.get('report'),
'package_vulnerabilities': ctx.obj.get('package-vulnerabilities'),
'license_compliance': ctx.obj.get('license-compliance'),
'maintainability': ctx.obj.get('unmaintained-packages', False),
'command_type': ctx.info_name.replace('-', '_'), # save backward compatibility
'aggregation_id': str(generate_unique_scan_id()),
'cli_start_time': _BOOT_WALL,
Expand Down
1 change: 1 addition & 0 deletions cycode/cli/cli_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions cycode/cli/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 3 additions & 7 deletions cycode/cli/printers/rich_printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_policy_details import get_sca_policy_details

if TYPE_CHECKING:
from cycode.cli.models import CliError, Detection, Document, LocalScanResult
Expand Down Expand Up @@ -86,19 +87,14 @@ 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'))

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 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:
Expand Down
16 changes: 15 additions & 1 deletion cycode/cli/printers/tables/sca_table_printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,18 @@
from typing import TYPE_CHECKING

from cycode.cli.cli_types import SeverityOption
from cycode.cli.consts import LICENSE_COMPLIANCE_POLICY_ID, PACKAGE_VULNERABILITY_POLICY_ID
from cycode.cli.consts import (
LICENSE_COMPLIANCE_POLICY_ID,
PACKAGE_VULNERABILITY_POLICY_ID,
UNMAINTAINED_PACKAGE_POLICY_ID,
)
from cycode.cli.models import Detection
from cycode.cli.printers.tables.table import Table
from cycode.cli.printers.tables.table_models import ColumnInfoBuilder
from cycode.cli.printers.tables.table_printer_base import TablePrinterBase
from cycode.cli.printers.utils import is_git_diff_based_scan
from cycode.cli.printers.utils.detection_ordering.sca_ordering import sort_and_group_detections
from cycode.cli.printers.utils.sca_ossf import get_maintained_score
from cycode.cli.utils.string_utils import shortcut_dependency_paths

if TYPE_CHECKING:
Expand All @@ -23,6 +28,7 @@
ECOSYSTEM_COLUMN = column_builder.build(name='Ecosystem', highlight=False)
PACKAGE_COLUMN = column_builder.build(name='Package', highlight=False)
CVE_COLUMNS = column_builder.build(name='CVE', highlight=False)
MAINTAINED_SCORE_COLUMN = column_builder.build(name='Maintained Score', highlight=False)
DEPENDENCY_PATHS_COLUMN = column_builder.build(name='Dependency Paths')
UPGRADE_COLUMN = column_builder.build(name='Upgrade')
LICENSE_COLUMN = column_builder.build(name='License', highlight=False)
Expand Down Expand Up @@ -51,6 +57,8 @@ def _get_title(policy_id: str) -> str:
return 'Dependency Vulnerabilities'
if policy_id == LICENSE_COMPLIANCE_POLICY_ID:
return 'License Compliance'
if policy_id == UNMAINTAINED_PACKAGE_POLICY_ID:
return 'Unmaintained Packages'

return 'Unknown'

Expand All @@ -62,6 +70,8 @@ def _get_table(self, policy_id: str) -> Table:
table.add_column(UPGRADE_COLUMN)
elif policy_id == LICENSE_COMPLIANCE_POLICY_ID:
table.add_column(LICENSE_COLUMN)
elif policy_id == UNMAINTAINED_PACKAGE_POLICY_ID:
table.add_column(MAINTAINED_SCORE_COLUMN)

if is_git_diff_based_scan(self.command_scan_type):
table.add_column(REPOSITORY_COLUMN)
Expand Down Expand Up @@ -120,6 +130,10 @@ 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'))

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}[/]')

Expand Down
14 changes: 2 additions & 12 deletions cycode/cli/printers/text_printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_policy_details import get_sca_policy_details

if TYPE_CHECKING:
from cycode.cli.models import Detection, LocalScanResult
Expand Down Expand Up @@ -82,18 +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.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(
Expand Down
23 changes: 23 additions & 0 deletions cycode/cli/printers/utils/sca_ossf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from typing import Any, Optional

_MAINTAINED_CHECK_NAME = 'maintained'


def _get_ossf_details(detection_details: dict) -> dict:
return detection_details.get('ossf') or {}


def get_ossf_score(detection_details: dict) -> Optional[Any]:
return _get_ossf_details(detection_details).get('score')


def get_ossf_report_url(detection_details: dict) -> Optional[str]:
return _get_ossf_details(detection_details).get('scorecard_report_url')


def get_maintained_score(detection_details: dict) -> Optional[Any]:
for check in _get_ossf_details(detection_details).get('checks') or []:
if str(check.get('name', '')).lower() == _MAINTAINED_CHECK_NAME:
return check.get('score')

return None
57 changes: 57 additions & 0 deletions cycode/cli/printers/utils/sca_policy_details.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from typing import TYPE_CHECKING, Callable

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:
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 [
('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]]:
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 []
29 changes: 29 additions & 0 deletions tests/cli/commands/scan/test_scan_parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -113,3 +115,30 @@ def test_get_scan_parameters_branch_with_various_names(mock_get_remote_url: Magi
mock_context.obj['branch'] = 'release-v1.0.0'
params = get_scan_parameters(mock_context, paths)
assert params['branch'] == 'release-v1.0.0'


def test_get_default_scan_parameters_maintainability_uses_unmaintained_packages_context_key(
mock_context: MagicMock,
) -> None:
"""Test that the maintainability wire parameter is taken from the unmaintained-packages context key."""
mock_context.obj['unmaintained-packages'] = False

params = _get_default_scan_parameters(mock_context)

assert params['maintainability'] is False
assert 'unmaintained_packages' not in params


def test_get_default_scan_parameters_maintainability_filters_out_when_not_selected(
mock_context: MagicMock,
) -> None:
"""Test that narrowing --sca-scan sends an explicit False rather than omitting the parameter.

The backend treats a missing value as "no opinion" so that CLI versions predating the option still get the
policy. A narrowed selection is an opinion, so it has to say False out loud.
"""
mock_context.obj.pop('unmaintained-packages')

params = _get_default_scan_parameters(mock_context)

assert params['maintainability'] is False
Loading
Loading