Skip to content

Commit 435afef

Browse files
AradTraubclaude
andauthored
CM-68446: Render unmaintained package detections in SCA output (#523)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 0fe1d03 commit 435afef

14 files changed

Lines changed: 496 additions & 22 deletions

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -789,7 +789,7 @@ The Cycode CLI application offers several types of scans so that you can choose
789789
| `--show-secret BOOLEAN` | Show secrets in plain text. See [Show/Hide Secrets](#showhide-secrets) section for more details. |
790790
| `--soft-fail BOOLEAN` | Run scan without failing, always return a non-error status code. See [Soft Fail](#soft-fail) section for more details. |
791791
| `--severity-threshold [INFO\|LOW\|MEDIUM\|HIGH\|CRITICAL]` | Show only violations at the specified level or higher. |
792-
| `--sca-scan` | Specify the SCA scan you wish to execute (`package-vulnerabilities`/`license-compliance`). The default is both. |
792+
| `--sca-scan` | Specify the SCA scan you wish to execute (`package-vulnerabilities`/`license-compliance`/`unmaintained-packages`). The default is all. |
793793
| `--monitor` | When specified, the scan results will be recorded in Cycode. |
794794
| `--cycode-report` | Display a link to the scan report in the Cycode platform in the console output. |
795795
| `--no-restore` | When specified, Cycode will not run the restore command. This will scan direct dependencies ONLY! |
@@ -867,6 +867,20 @@ In the previous example, if you wanted to only scan a branch named `dev`, you co
867867

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

870+
#### Unmaintained Packages Option
871+
872+
> [!NOTE]
873+
> This option is only available to SCA scans.
874+
875+
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.
876+
877+
> [!NOTE]
878+
> 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.
879+
880+
In the previous example, if you wanted to only run an SCA scan on unmaintained packages, you could execute the following:
881+
882+
`cycode scan -t sca --sca-scan unmaintained-packages repository ~/home/git/codebase`
883+
870884
#### Lock Restore Option
871885
872886
> [!NOTE]

cycode/cli/apps/scan/scan_command.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,11 @@ def scan_command(
9393
help='Specify the type of SCA scan you wish to execute.',
9494
rich_help_panel=_SCA_RICH_HELP_PANEL,
9595
),
96-
] = (ScaScanTypeOption.PACKAGE_VULNERABILITIES, ScaScanTypeOption.LICENSE_COMPLIANCE),
96+
] = (
97+
ScaScanTypeOption.PACKAGE_VULNERABILITIES,
98+
ScaScanTypeOption.LICENSE_COMPLIANCE,
99+
ScaScanTypeOption.UNMAINTAINED_PACKAGES,
100+
),
97101
monitor: Annotated[
98102
bool,
99103
typer.Option(

cycode/cli/apps/scan/scan_parameters.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ def _get_default_scan_parameters(ctx: typer.Context) -> dict:
1616
'report': ctx.obj.get('report'),
1717
'package_vulnerabilities': ctx.obj.get('package-vulnerabilities'),
1818
'license_compliance': ctx.obj.get('license-compliance'),
19+
'maintainability': ctx.obj.get('unmaintained-packages', False),
1920
'command_type': ctx.info_name.replace('-', '_'), # save backward compatibility
2021
'aggregation_id': str(generate_unique_scan_id()),
2122
'cli_start_time': _BOOT_WALL,

cycode/cli/cli_types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ def __str__(self) -> str:
4040
class ScaScanTypeOption(StrEnum):
4141
PACKAGE_VULNERABILITIES = 'package-vulnerabilities'
4242
LICENSE_COMPLIANCE = 'license-compliance'
43+
UNMAINTAINED_PACKAGES = 'unmaintained-packages'
4344

4445

4546
class SbomFormatOption(StrEnum):

cycode/cli/consts.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,7 @@
314314

315315
LICENSE_COMPLIANCE_POLICY_ID = '8f681450-49e1-4f7e-85b7-0c8fe84b3a35'
316316
PACKAGE_VULNERABILITY_POLICY_ID = '9369d10a-9ac0-48d3-9921-5de7fe9a37a7'
317+
UNMAINTAINED_PACKAGE_POLICY_ID = '7b45ee1f-ee08-4353-a00a-2586db27b0f1'
317318

318319
# Shortcut dependency paths by remove all middle dependencies
319320
# between direct dependency and influence/vulnerable dependency.

cycode/cli/printers/rich_printer.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
)
1717
from cycode.cli.printers.utils.detection_ordering.common_ordering import sort_and_group_detections_from_scan_result
1818
from cycode.cli.printers.utils.rich_helpers import get_columns_in_1_to_3_ratio, get_markdown_panel, get_panel
19+
from cycode.cli.printers.utils.sca_policy_details import get_sca_policy_details
1920

2021
if TYPE_CHECKING:
2122
from cycode.cli.models import CliError, Detection, Document, LocalScanResult
@@ -86,19 +87,14 @@ def __add_secret_scan_related_rows(details_table: Table, detection: 'Detection')
8687
def __add_sca_scan_related_rows(details_table: Table, detection: 'Detection') -> None:
8788
detection_details = detection.detection_details
8889

89-
details_table.add_row('CVEs', get_detection_clickable_cwe_cve(consts.SCA_SCAN_TYPE, detection))
9090
details_table.add_row('Package', detection_details.get('package_name'))
9191
details_table.add_row('Version', detection_details.get('package_version'))
9292

93-
if detection.has_alert:
94-
patched_version = detection_details['alert'].get('first_patched_version')
95-
details_table.add_row('First patched version', patched_version or 'Not fixed')
96-
9793
dependency_path = detection_details.get('dependency_paths')
9894
details_table.add_row('Dependency path', dependency_path or 'N/A')
9995

100-
if not detection.has_alert:
101-
details_table.add_row('License', detection_details.get('license'))
96+
for label, value in get_sca_policy_details(detection):
97+
details_table.add_row(label, value)
10298

10399
@staticmethod
104100
def __add_iac_scan_related_rows(details_table: Table, detection: 'Detection') -> None:

cycode/cli/printers/tables/sca_table_printer.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,18 @@
22
from typing import TYPE_CHECKING
33

44
from cycode.cli.cli_types import SeverityOption
5-
from cycode.cli.consts import LICENSE_COMPLIANCE_POLICY_ID, PACKAGE_VULNERABILITY_POLICY_ID
5+
from cycode.cli.consts import (
6+
LICENSE_COMPLIANCE_POLICY_ID,
7+
PACKAGE_VULNERABILITY_POLICY_ID,
8+
UNMAINTAINED_PACKAGE_POLICY_ID,
9+
)
610
from cycode.cli.models import Detection
711
from cycode.cli.printers.tables.table import Table
812
from cycode.cli.printers.tables.table_models import ColumnInfoBuilder
913
from cycode.cli.printers.tables.table_printer_base import TablePrinterBase
1014
from cycode.cli.printers.utils import is_git_diff_based_scan
1115
from cycode.cli.printers.utils.detection_ordering.sca_ordering import sort_and_group_detections
16+
from cycode.cli.printers.utils.sca_ossf import get_maintained_score
1217
from cycode.cli.utils.string_utils import shortcut_dependency_paths
1318

1419
if TYPE_CHECKING:
@@ -23,6 +28,7 @@
2328
ECOSYSTEM_COLUMN = column_builder.build(name='Ecosystem', highlight=False)
2429
PACKAGE_COLUMN = column_builder.build(name='Package', highlight=False)
2530
CVE_COLUMNS = column_builder.build(name='CVE', highlight=False)
31+
MAINTAINED_SCORE_COLUMN = column_builder.build(name='Maintained Score', highlight=False)
2632
DEPENDENCY_PATHS_COLUMN = column_builder.build(name='Dependency Paths')
2733
UPGRADE_COLUMN = column_builder.build(name='Upgrade')
2834
LICENSE_COLUMN = column_builder.build(name='License', highlight=False)
@@ -51,6 +57,8 @@ def _get_title(policy_id: str) -> str:
5157
return 'Dependency Vulnerabilities'
5258
if policy_id == LICENSE_COMPLIANCE_POLICY_ID:
5359
return 'License Compliance'
60+
if policy_id == UNMAINTAINED_PACKAGE_POLICY_ID:
61+
return 'Unmaintained Packages'
5462

5563
return 'Unknown'
5664

@@ -62,6 +70,8 @@ def _get_table(self, policy_id: str) -> Table:
6270
table.add_column(UPGRADE_COLUMN)
6371
elif policy_id == LICENSE_COMPLIANCE_POLICY_ID:
6472
table.add_column(LICENSE_COLUMN)
73+
elif policy_id == UNMAINTAINED_PACKAGE_POLICY_ID:
74+
table.add_column(MAINTAINED_SCORE_COLUMN)
6575

6676
if is_git_diff_based_scan(self.command_scan_type):
6777
table.add_column(REPOSITORY_COLUMN)
@@ -120,6 +130,10 @@ def _enrich_table_with_values(table: Table, detection: Detection) -> None:
120130
table.add_cell(CVE_COLUMNS, detection_details.get('vulnerability_id'))
121131
table.add_cell(LICENSE_COLUMN, detection_details.get('license'))
122132

133+
if detection.detection_type_id == UNMAINTAINED_PACKAGE_POLICY_ID:
134+
maintained_score = get_maintained_score(detection_details)
135+
table.add_cell(MAINTAINED_SCORE_COLUMN, 'N/A' if maintained_score is None else str(maintained_score))
136+
123137
def _print_summary_issues(self, detections_count: int, title: str) -> None:
124138
self.console.print(f'[bold]Cycode found {detections_count} violations of type: [cyan]{title}[/]')
125139

cycode/cli/printers/text_printer.py

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from cycode.cli.printers.utils.code_snippet_syntax import get_code_snippet_syntax, get_detection_line
88
from cycode.cli.printers.utils.detection_data import get_detection_title
99
from cycode.cli.printers.utils.detection_ordering.common_ordering import sort_and_group_detections_from_scan_result
10+
from cycode.cli.printers.utils.sca_policy_details import get_sca_policy_details
1011

1112
if TYPE_CHECKING:
1213
from cycode.cli.models import Detection, LocalScanResult
@@ -82,18 +83,7 @@ def __get_intermediate_summary_lines(self, detection: 'Detection') -> list[str]:
8283

8384
@staticmethod
8485
def __get_sca_related_summary_lines(detection: 'Detection') -> list[str]:
85-
summary_lines = []
86-
87-
if detection.has_alert:
88-
patched_version = detection.detection_details['alert'].get('first_patched_version')
89-
patched_version = patched_version or 'Not fixed'
90-
91-
summary_lines.append(f'First patched version: [cyan]{patched_version}[/]\n')
92-
else:
93-
package_license = detection.detection_details.get('license', 'N/A')
94-
summary_lines.append(f'License: [cyan]{package_license}[/]\n')
95-
96-
return summary_lines
86+
return [f'{label}: [cyan]{value}[/]\n' for label, value in get_sca_policy_details(detection)]
9787

9888
def __print_detection_code_segment(self, detection: 'Detection', document: Document) -> None:
9989
self.console.print(
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from typing import Any, Optional
2+
3+
_MAINTAINED_CHECK_NAME = 'maintained'
4+
5+
6+
def _get_ossf_details(detection_details: dict) -> dict:
7+
return detection_details.get('ossf') or {}
8+
9+
10+
def get_ossf_score(detection_details: dict) -> Optional[Any]:
11+
return _get_ossf_details(detection_details).get('score')
12+
13+
14+
def get_ossf_report_url(detection_details: dict) -> Optional[str]:
15+
return _get_ossf_details(detection_details).get('scorecard_report_url')
16+
17+
18+
def get_maintained_score(detection_details: dict) -> Optional[Any]:
19+
for check in _get_ossf_details(detection_details).get('checks') or []:
20+
if str(check.get('name', '')).lower() == _MAINTAINED_CHECK_NAME:
21+
return check.get('score')
22+
23+
return None
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from typing import TYPE_CHECKING, Callable
2+
3+
from cycode.cli.consts import (
4+
LICENSE_COMPLIANCE_POLICY_ID,
5+
PACKAGE_VULNERABILITY_POLICY_ID,
6+
SCA_SCAN_TYPE,
7+
UNMAINTAINED_PACKAGE_POLICY_ID,
8+
)
9+
from cycode.cli.printers.utils.detection_data import get_detection_clickable_cwe_cve
10+
from cycode.cli.printers.utils.sca_ossf import get_maintained_score, get_ossf_report_url, get_ossf_score
11+
12+
if TYPE_CHECKING:
13+
from cycode.cyclient.models import Detection
14+
15+
_NOT_AVAILABLE = 'N/A'
16+
17+
18+
def _package_vulnerability_details(detection: 'Detection') -> list[tuple[str, str]]:
19+
alert = detection.detection_details.get('alert') or {}
20+
return [
21+
('CVEs', get_detection_clickable_cwe_cve(SCA_SCAN_TYPE, detection) or _NOT_AVAILABLE),
22+
('First patched version', alert.get('first_patched_version') or 'Not fixed'),
23+
]
24+
25+
26+
def _license_compliance_details(detection: 'Detection') -> list[tuple[str, str]]:
27+
return [('License', detection.detection_details.get('license') or _NOT_AVAILABLE)]
28+
29+
30+
def _unmaintained_package_details(detection: 'Detection') -> list[tuple[str, str]]:
31+
detection_details = detection.detection_details
32+
maintained_score = get_maintained_score(detection_details)
33+
ossf_score = get_ossf_score(detection_details)
34+
35+
return [
36+
('Maintained score', _NOT_AVAILABLE if maintained_score is None else str(maintained_score)),
37+
('OSSF Scorecard score', _NOT_AVAILABLE if ossf_score is None else str(ossf_score)),
38+
('Scorecard report', get_ossf_report_url(detection_details) or _NOT_AVAILABLE),
39+
]
40+
41+
42+
_DETAILS_BY_POLICY: dict[str, Callable[['Detection'], list[tuple[str, str]]]] = {
43+
PACKAGE_VULNERABILITY_POLICY_ID: _package_vulnerability_details,
44+
LICENSE_COMPLIANCE_POLICY_ID: _license_compliance_details,
45+
UNMAINTAINED_PACKAGE_POLICY_ID: _unmaintained_package_details,
46+
}
47+
48+
49+
def get_sca_policy_details(detection: 'Detection') -> list[tuple[str, str]]:
50+
"""Labelled fields specific to the SCA policy that raised the detection, in display order.
51+
52+
A policy with no entry contributes nothing rather than borrowing another policy's fields, so a new one shows
53+
no details until it is added here.
54+
"""
55+
build_details = _DETAILS_BY_POLICY.get(detection.detection_type_id)
56+
57+
return build_details(detection) if build_details else []

0 commit comments

Comments
 (0)