Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 8 additions & 1 deletion 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_ossf import get_maintained_score, get_ossf_report_url, get_ossf_score

if TYPE_CHECKING:
from cycode.cli.models import CliError, Detection, Document, LocalScanResult
Expand Down Expand Up @@ -97,7 +98,13 @@ def __add_sca_scan_related_rows(details_table: Table, detection: 'Detection') ->
dependency_path = detection_details.get('dependency_paths')
details_table.add_row('Dependency path', dependency_path or 'N/A')

if not detection.has_alert:
if detection.detection_type_id == consts.UNMAINTAINED_PACKAGE_POLICY_ID:
maintained_score = get_maintained_score(detection_details)
ossf_score = get_ossf_score(detection_details)
details_table.add_row('Maintained score', 'N/A' if maintained_score is None else str(maintained_score))
details_table.add_row('OSSF Scorecard score', 'N/A' if ossf_score is None else str(ossf_score))
details_table.add_row('Scorecard report', get_ossf_report_url(detection_details) or 'N/A')
elif not detection.has_alert:
Comment on lines +101 to +107

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this actually be conditional?

details_table.add_row('License', detection_details.get('license'))

@staticmethod
Expand Down
15 changes: 14 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,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))
Comment on lines +133 to +134

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please put this being policy gate (score is irrelevant for other policies


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
13 changes: 12 additions & 1 deletion 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_ossf import get_maintained_score, get_ossf_report_url, get_ossf_score

if TYPE_CHECKING:
from cycode.cli.models import Detection, LocalScanResult
Expand Down Expand Up @@ -84,7 +85,17 @@ def __get_intermediate_summary_lines(self, detection: 'Detection') -> list[str]:
def __get_sca_related_summary_lines(detection: 'Detection') -> list[str]:
summary_lines = []

if detection.has_alert:
if detection.detection_type_id == consts.UNMAINTAINED_PACKAGE_POLICY_ID:
maintained_score = get_maintained_score(detection.detection_details)
ossf_score = get_ossf_score(detection.detection_details)
maintained = 'N/A' if maintained_score is None else maintained_score
score = 'N/A' if ossf_score is None else ossf_score
report_url = get_ossf_report_url(detection.detection_details) or 'N/A'

summary_lines.append(f'Maintained score: [cyan]{maintained}[/]\n')
summary_lines.append(f'OSSF Scorecard score: [cyan]{score}[/]\n')
summary_lines.append(f'Scorecard report: [cyan]{report_url}[/]\n')
elif detection.has_alert:
Comment on lines +88 to +98

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this actually be conditional?

patched_version = detection.detection_details['alert'].get('first_patched_version')
patched_version = patched_version or 'Not fixed'

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
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
112 changes: 112 additions & 0 deletions tests/cli/printers/test_sca_table_printer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
from unittest.mock import MagicMock

import pytest
from rich.console import Console

from cycode.cli.consts import (
LICENSE_COMPLIANCE_POLICY_ID,
PACKAGE_VULNERABILITY_POLICY_ID,
UNMAINTAINED_PACKAGE_POLICY_ID,
)
from cycode.cli.printers.tables.sca_table_printer import (
CVE_COLUMNS,
LICENSE_COLUMN,
MAINTAINED_SCORE_COLUMN,
UPGRADE_COLUMN,
ScaTablePrinter,
)
from cycode.cyclient.models import Detection


@pytest.fixture
def printer() -> ScaTablePrinter:
ctx = MagicMock()
ctx.obj = {'scan_type': 'sca'}
ctx.info_name = 'path'
return ScaTablePrinter(ctx, Console(), Console(stderr=True))


def _make_detection(policy_id: str, **details: object) -> Detection:
return Detection(
detection_type_id=policy_id,
type='Unmaintained packages',
message='Package is unmaintained',
detection_details=dict(details),
detection_rule_id='rule-id',
severity='Medium',
)


def test_get_title_unmaintained_packages() -> None:
assert ScaTablePrinter._get_title(UNMAINTAINED_PACKAGE_POLICY_ID) == 'Unmaintained Packages'


def test_get_title_known_policies_are_not_changed() -> None:
assert ScaTablePrinter._get_title(PACKAGE_VULNERABILITY_POLICY_ID) == 'Dependency Vulnerabilities'
assert ScaTablePrinter._get_title(LICENSE_COMPLIANCE_POLICY_ID) == 'License Compliance'


def test_get_title_unknown_policy() -> None:
assert ScaTablePrinter._get_title('not-a-known-policy-id') == 'Unknown'


def test_get_table_unmaintained_packages_columns(printer: ScaTablePrinter) -> None:
columns = printer._get_table(UNMAINTAINED_PACKAGE_POLICY_ID).get_columns_info()

assert MAINTAINED_SCORE_COLUMN in columns
assert CVE_COLUMNS not in columns
assert UPGRADE_COLUMN not in columns
assert LICENSE_COLUMN not in columns


def test_get_table_unmaintained_packages_column_order(printer: ScaTablePrinter) -> None:
column_names = [column.name for column in printer._get_table(UNMAINTAINED_PACKAGE_POLICY_ID).get_columns_info()]

assert column_names == [
'Severity',
'Code Project',
'Ecosystem',
'Package',
'Maintained Score',
'Dependency Paths',
'Direct Dependency',
'Development Dependency',
]


def test_get_table_other_policies_do_not_get_the_score_column(printer: ScaTablePrinter) -> None:
assert MAINTAINED_SCORE_COLUMN not in printer._get_table(PACKAGE_VULNERABILITY_POLICY_ID).get_columns_info()
assert MAINTAINED_SCORE_COLUMN not in printer._get_table(LICENSE_COMPLIANCE_POLICY_ID).get_columns_info()


def test_enrich_table_with_values_populates_the_score(printer: ScaTablePrinter) -> None:
table = printer._get_table(UNMAINTAINED_PACKAGE_POLICY_ID)
detection = _make_detection(
UNMAINTAINED_PACKAGE_POLICY_ID,
file_path='/repo/package.json',
ecosystem='npm',
package_name='left-pad',
package_version='1.0.0',
ossf={
'score': 4.1,
'scorecard_report_url': 'https://scorecard.dev/viewer/?uri=github.com/example/left-pad',
'checks': [{'name': 'Maintained', 'score': 1.5, 'reason': 'no recent activity'}],
},
)

ScaTablePrinter._enrich_table_with_values(table, detection)

row = table.get_rows()[0]
score_index = table.get_columns_info().index(MAINTAINED_SCORE_COLUMN)
assert row[score_index] == '1.5'


def test_enrich_table_with_values_missing_score(printer: ScaTablePrinter) -> None:
table = printer._get_table(UNMAINTAINED_PACKAGE_POLICY_ID)
detection = _make_detection(UNMAINTAINED_PACKAGE_POLICY_ID, file_path='/repo/package.json', package_name='left-pad')

ScaTablePrinter._enrich_table_with_values(table, detection)

row = table.get_rows()[0]
score_index = table.get_columns_info().index(MAINTAINED_SCORE_COLUMN)
assert row[score_index] == 'N/A'
Loading
Loading