diff --git a/README.md b/README.md index da428ec8..fb6961c8 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`/`unmaintained-packages`/`malicious-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! | @@ -881,6 +881,22 @@ In the previous example, if you wanted to only run an SCA scan on unmaintained p `cycode scan -t sca --sca-scan unmaintained-packages repository ~/home/git/codebase` +#### Malicious Packages Option + +> [!NOTE] +> This option is only available to SCA scans. + +To scan only for malicious packages (dependencies that a security advisory has identified as malware rather than merely vulnerable), add the argument `--sca-scan malicious-packages` following the `-t sca` or `--scan-type sca` option. + +Malicious package violations are always reported as `Critical`, and carry no CVSS score and no fix version: the only remediation is removing the dependency. + +> [!NOTE] +> Whether malicious 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 malicious packages, you could execute the following: + +`cycode scan -t sca --sca-scan malicious-packages repository ~/home/git/codebase` + #### Lock Restore Option > [!NOTE] diff --git a/cycode/cli/apps/scan/scan_command.py b/cycode/cli/apps/scan/scan_command.py index 48c425b9..b857438e 100644 --- a/cycode/cli/apps/scan/scan_command.py +++ b/cycode/cli/apps/scan/scan_command.py @@ -97,6 +97,7 @@ def scan_command( ScaScanTypeOption.PACKAGE_VULNERABILITIES, ScaScanTypeOption.LICENSE_COMPLIANCE, ScaScanTypeOption.UNMAINTAINED_PACKAGES, + ScaScanTypeOption.MALICIOUS_PACKAGES, ), monitor: Annotated[ bool, diff --git a/cycode/cli/apps/scan/scan_parameters.py b/cycode/cli/apps/scan/scan_parameters.py index d297010a..fba43a5c 100644 --- a/cycode/cli/apps/scan/scan_parameters.py +++ b/cycode/cli/apps/scan/scan_parameters.py @@ -17,6 +17,7 @@ def _get_default_scan_parameters(ctx: typer.Context) -> dict: 'package_vulnerabilities': ctx.obj.get('package-vulnerabilities'), 'license_compliance': ctx.obj.get('license-compliance'), 'maintainability': ctx.obj.get('unmaintained-packages', False), + 'malicious_packages': ctx.obj.get('malicious-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/cycode/cli/cli_types.py b/cycode/cli/cli_types.py index 8b68d7af..b1f93ee9 100644 --- a/cycode/cli/cli_types.py +++ b/cycode/cli/cli_types.py @@ -41,6 +41,7 @@ class ScaScanTypeOption(StrEnum): PACKAGE_VULNERABILITIES = 'package-vulnerabilities' LICENSE_COMPLIANCE = 'license-compliance' UNMAINTAINED_PACKAGES = 'unmaintained-packages' + MALICIOUS_PACKAGES = 'malicious-packages' class SbomFormatOption(StrEnum): diff --git a/cycode/cli/consts.py b/cycode/cli/consts.py index 104cfc9b..1447c3ff 100644 --- a/cycode/cli/consts.py +++ b/cycode/cli/consts.py @@ -315,6 +315,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' +MALICIOUS_PACKAGE_POLICY_ID = '1fe6d83a-19de-48a2-a5ba-a80fe648489b' # Shortcut dependency paths by remove all middle dependencies # between direct dependency and influence/vulnerable dependency. diff --git a/cycode/cli/printers/tables/sca_table_printer.py b/cycode/cli/printers/tables/sca_table_printer.py index 9992adc3..19d57544 100644 --- a/cycode/cli/printers/tables/sca_table_printer.py +++ b/cycode/cli/printers/tables/sca_table_printer.py @@ -4,6 +4,7 @@ from cycode.cli.cli_types import SeverityOption from cycode.cli.consts import ( LICENSE_COMPLIANCE_POLICY_ID, + MALICIOUS_PACKAGE_POLICY_ID, PACKAGE_VULNERABILITY_POLICY_ID, UNMAINTAINED_PACKAGE_POLICY_ID, ) @@ -28,6 +29,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) +ADVISORY_COLUMN = column_builder.build(name='Advisory', 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') @@ -59,6 +61,8 @@ def _get_title(policy_id: str) -> str: return 'License Compliance' if policy_id == UNMAINTAINED_PACKAGE_POLICY_ID: return 'Unmaintained Packages' + if policy_id == MALICIOUS_PACKAGE_POLICY_ID: + return 'Malicious Packages' return 'Unknown' @@ -72,6 +76,8 @@ def _get_table(self, policy_id: str) -> Table: table.add_column(LICENSE_COLUMN) elif policy_id == UNMAINTAINED_PACKAGE_POLICY_ID: table.add_column(MAINTAINED_SCORE_COLUMN) + elif policy_id == MALICIOUS_PACKAGE_POLICY_ID: + table.add_column(ADVISORY_COLUMN) if is_git_diff_based_scan(self.command_scan_type): table.add_column(REPOSITORY_COLUMN) @@ -128,6 +134,7 @@ def _enrich_table_with_values(table: Table, detection: Detection) -> None: table.add_cell(UPGRADE_COLUMN, upgrade) table.add_cell(CVE_COLUMNS, detection_details.get('vulnerability_id')) + table.add_cell(ADVISORY_COLUMN, detection_details.get('threat_id') or 'N/A') table.add_cell(LICENSE_COLUMN, detection_details.get('license')) if detection.detection_type_id == UNMAINTAINED_PACKAGE_POLICY_ID: diff --git a/cycode/cli/printers/utils/sca_policy_details.py b/cycode/cli/printers/utils/sca_policy_details.py index 757c1be0..b86e3ca8 100644 --- a/cycode/cli/printers/utils/sca_policy_details.py +++ b/cycode/cli/printers/utils/sca_policy_details.py @@ -2,6 +2,7 @@ from cycode.cli.consts import ( LICENSE_COMPLIANCE_POLICY_ID, + MALICIOUS_PACKAGE_POLICY_ID, PACKAGE_VULNERABILITY_POLICY_ID, SCA_SCAN_TYPE, UNMAINTAINED_PACKAGE_POLICY_ID, @@ -14,6 +15,11 @@ _NOT_AVAILABLE = 'N/A' +# Malware carries no CVSS and no patched version, so removal is the whole remediation. +_MALICIOUS_PACKAGE_REMEDIATION = ( + 'This package has been identified as malicious. Remove it from your dependencies immediately.' +) + def _package_vulnerability_details(detection: 'Detection') -> list[tuple[str, str]]: alert = detection.detection_details.get('alert') or {} @@ -39,10 +45,30 @@ def _unmaintained_package_details(detection: 'Detection') -> list[tuple[str, str ] +def _malicious_package_details(detection: 'Detection') -> list[tuple[str, str]]: + detection_details = detection.detection_details + + # A MAL- id matches none of the CVE/GHSA/CWE URL shapes, so the advisory URL comes from the detection. + threat_id = detection_details.get('threat_id') + advisory_url = detection_details.get('advisory_url') + if not threat_id: + advisory = _NOT_AVAILABLE + elif advisory_url: + advisory = f'[link={advisory_url}]{threat_id}[/]' + else: + advisory = threat_id + + return [ + ('Advisory', advisory), + ('Remediation', _MALICIOUS_PACKAGE_REMEDIATION), + ] + + _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, + MALICIOUS_PACKAGE_POLICY_ID: _malicious_package_details, } diff --git a/tests/cli/commands/scan/test_scan_parameters.py b/tests/cli/commands/scan/test_scan_parameters.py index a0286e31..f20e5017 100644 --- a/tests/cli/commands/scan/test_scan_parameters.py +++ b/tests/cli/commands/scan/test_scan_parameters.py @@ -15,6 +15,7 @@ def mock_context() -> MagicMock: 'package-vulnerabilities': True, 'license-compliance': True, 'unmaintained-packages': True, + 'malicious-packages': True, } ctx.info_name = 'test-command' return ctx @@ -29,6 +30,7 @@ def test_get_default_scan_parameters(mock_context: MagicMock) -> None: assert params['package_vulnerabilities'] is True assert params['license_compliance'] is True assert params['maintainability'] is True + assert params['malicious_packages'] is True assert params['command_type'] == 'test_command' # hyphens replaced with underscores assert 'aggregation_id' in params @@ -142,3 +144,30 @@ def test_get_default_scan_parameters_maintainability_filters_out_when_not_select params = _get_default_scan_parameters(mock_context) assert params['maintainability'] is False + + +def test_get_default_scan_parameters_malicious_packages_uses_the_hyphenated_context_key( + mock_context: MagicMock, +) -> None: + """Test that the malicious_packages wire parameter is taken from the malicious-packages context key.""" + mock_context.obj['malicious-packages'] = False + + params = _get_default_scan_parameters(mock_context) + + assert params['malicious_packages'] is False + assert 'malicious-packages' not in params + + +def test_get_default_scan_parameters_malicious_packages_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 runs every SCA detector when no option is mentioned at all, so a narrowed selection has to say + False out loud or it would silently re-enable the detector it just excluded. + """ + mock_context.obj.pop('malicious-packages') + + params = _get_default_scan_parameters(mock_context) + + assert params['malicious_packages'] is False diff --git a/tests/cli/printers/test_sca_table_printer.py b/tests/cli/printers/test_sca_table_printer.py index 37fbfdba..58f967dd 100644 --- a/tests/cli/printers/test_sca_table_printer.py +++ b/tests/cli/printers/test_sca_table_printer.py @@ -5,10 +5,12 @@ from cycode.cli.consts import ( LICENSE_COMPLIANCE_POLICY_ID, + MALICIOUS_PACKAGE_POLICY_ID, PACKAGE_VULNERABILITY_POLICY_ID, UNMAINTAINED_PACKAGE_POLICY_ID, ) from cycode.cli.printers.tables.sca_table_printer import ( + ADVISORY_COLUMN, CVE_COLUMNS, LICENSE_COLUMN, MAINTAINED_SCORE_COLUMN, @@ -110,3 +112,67 @@ def test_enrich_table_with_values_missing_score(printer: ScaTablePrinter) -> Non row = table.get_rows()[0] score_index = table.get_columns_info().index(MAINTAINED_SCORE_COLUMN) assert row[score_index] == 'N/A' + + +def test_get_title_malicious_packages() -> None: + assert ScaTablePrinter._get_title(MALICIOUS_PACKAGE_POLICY_ID) == 'Malicious Packages' + + +def test_get_table_malicious_packages_columns(printer: ScaTablePrinter) -> None: + columns = printer._get_table(MALICIOUS_PACKAGE_POLICY_ID).get_columns_info() + + assert ADVISORY_COLUMN in columns + assert CVE_COLUMNS not in columns + assert UPGRADE_COLUMN not in columns + assert LICENSE_COLUMN not in columns + assert MAINTAINED_SCORE_COLUMN not in columns + + +def test_get_table_malicious_packages_column_order(printer: ScaTablePrinter) -> None: + column_names = [column.name for column in printer._get_table(MALICIOUS_PACKAGE_POLICY_ID).get_columns_info()] + + assert column_names == [ + 'Severity', + 'Code Project', + 'Ecosystem', + 'Package', + 'Advisory', + 'Dependency Paths', + 'Direct Dependency', + 'Development Dependency', + ] + + +def test_get_table_other_policies_do_not_get_the_advisory_column(printer: ScaTablePrinter) -> None: + for policy_id in (PACKAGE_VULNERABILITY_POLICY_ID, LICENSE_COMPLIANCE_POLICY_ID, UNMAINTAINED_PACKAGE_POLICY_ID): + assert ADVISORY_COLUMN not in printer._get_table(policy_id).get_columns_info() + + +def test_enrich_table_with_values_populates_the_advisory(printer: ScaTablePrinter) -> None: + table = printer._get_table(MALICIOUS_PACKAGE_POLICY_ID) + detection = _make_detection( + MALICIOUS_PACKAGE_POLICY_ID, + file_path='/repo/package.json', + ecosystem='npm', + package_name='evil-pkg', + package_version='1.0.0', + threat_id='MAL-2024-1234', + advisory_url='https://osv.dev/vulnerability/MAL-2024-1234', + ) + + ScaTablePrinter._enrich_table_with_values(table, detection) + + row = table.get_rows()[0] + advisory_index = table.get_columns_info().index(ADVISORY_COLUMN) + assert row[advisory_index] == 'MAL-2024-1234' + + +def test_enrich_table_with_values_missing_advisory(printer: ScaTablePrinter) -> None: + table = printer._get_table(MALICIOUS_PACKAGE_POLICY_ID) + detection = _make_detection(MALICIOUS_PACKAGE_POLICY_ID, file_path='/repo/package.json', package_name='evil-pkg') + + ScaTablePrinter._enrich_table_with_values(table, detection) + + row = table.get_rows()[0] + advisory_index = table.get_columns_info().index(ADVISORY_COLUMN) + assert row[advisory_index] == 'N/A' diff --git a/tests/cli/printers/test_text_printer.py b/tests/cli/printers/test_text_printer.py index f973ca93..9ae55b51 100644 --- a/tests/cli/printers/test_text_printer.py +++ b/tests/cli/printers/test_text_printer.py @@ -6,6 +6,7 @@ from cycode.cli.consts import ( LICENSE_COMPLIANCE_POLICY_ID, + MALICIOUS_PACKAGE_POLICY_ID, PACKAGE_VULNERABILITY_POLICY_ID, UNMAINTAINED_PACKAGE_POLICY_ID, ) @@ -127,3 +128,43 @@ def test_package_vulnerability_still_prints_the_patched_version(printer: TextPri assert 'First patched version: 4.17.21' in result assert 'OSSF' not in result + + +def test_malicious_package_prints_the_advisory_and_the_removal_instruction( + printer: TextPrinter, output: io.StringIO +) -> None: + detection = _make_detection( + MALICIOUS_PACKAGE_POLICY_ID, + package_name='evil-pkg', + package_version='1.0.0', + threat_id='MAL-2024-1234', + advisory_url='https://osv.dev/vulnerability/MAL-2024-1234', + ) + + result = _render(printer, output, detection) + + assert 'Advisory: MAL-2024-1234' in result + assert 'Remove it from your dependencies immediately.' in result + + +def test_malicious_package_offers_no_fix_version(printer: TextPrinter, output: io.StringIO) -> None: + """Malware has no patched version, so the vulnerability policy's rows must not leak into it.""" + detection = _make_detection( + MALICIOUS_PACKAGE_POLICY_ID, + threat_id='MAL-2024-1234', + alert={'first_patched_version': '2.0.0'}, + ) + + result = _render(printer, output, detection) + + assert 'First patched version' not in result + assert 'CVEs' not in result + + +def test_malicious_package_without_an_advisory_id(printer: TextPrinter, output: io.StringIO) -> None: + detection = _make_detection(MALICIOUS_PACKAGE_POLICY_ID) + + result = _render(printer, output, detection) + + assert 'Advisory: N/A' in result + assert 'Remove it from your dependencies immediately.' in result diff --git a/tests/cli/printers/utils/test_sca_policy_details.py b/tests/cli/printers/utils/test_sca_policy_details.py index 83b7ddbd..093c3e63 100644 --- a/tests/cli/printers/utils/test_sca_policy_details.py +++ b/tests/cli/printers/utils/test_sca_policy_details.py @@ -1,5 +1,6 @@ from cycode.cli.consts import ( LICENSE_COMPLIANCE_POLICY_ID, + MALICIOUS_PACKAGE_POLICY_ID, PACKAGE_VULNERABILITY_POLICY_ID, UNMAINTAINED_PACKAGE_POLICY_ID, ) @@ -79,13 +80,58 @@ def test_unmaintained_package_without_a_scorecard() -> None: ] +MALICIOUS_REMEDIATION = 'This package has been identified as malicious. Remove it from your dependencies immediately.' + + +def test_malicious_package_links_the_advisory_and_tells_you_to_remove_it() -> None: + detection = _make_detection( + MALICIOUS_PACKAGE_POLICY_ID, + threat_id='MAL-2024-1234', + advisory_url='https://osv.dev/vulnerability/MAL-2024-1234', + ) + + assert get_sca_policy_details(detection) == [ + ('Advisory', '[link=https://osv.dev/vulnerability/MAL-2024-1234]MAL-2024-1234[/]'), + ('Remediation', MALICIOUS_REMEDIATION), + ] + + +def test_malicious_package_without_an_advisory_url_shows_the_bare_id() -> None: + detection = _make_detection(MALICIOUS_PACKAGE_POLICY_ID, threat_id='MAL-2024-1234') + + assert get_sca_policy_details(detection) == [ + ('Advisory', 'MAL-2024-1234'), + ('Remediation', MALICIOUS_REMEDIATION), + ] + + +def test_malicious_package_without_a_threat_id_still_tells_you_to_remove_it() -> None: + """The remediation never depends on the advisory: removal is the only fix whether or not we can name it.""" + detection = _make_detection(MALICIOUS_PACKAGE_POLICY_ID, advisory_url='https://osv.dev/vulnerability/MAL-2024-1234') + + assert get_sca_policy_details(detection) == [ + ('Advisory', 'N/A'), + ('Remediation', MALICIOUS_REMEDIATION), + ] + + +def test_malicious_package_reports_no_fix_version() -> None: + """Malware has no patched version, so the vulnerability policy's rows must not leak into it.""" + detection = _make_detection( + MALICIOUS_PACKAGE_POLICY_ID, threat_id='MAL-2024-1234', alert={'first_patched_version': '2.0.0'} + ) + + labels = [label for label, _ in get_sca_policy_details(detection)] + assert 'First patched version' not in labels + + 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): + for policy_id in (LICENSE_COMPLIANCE_POLICY_ID, UNMAINTAINED_PACKAGE_POLICY_ID, MALICIOUS_PACKAGE_POLICY_ID): detection = _make_detection(policy_id, vulnerability_id='CVE-2021-23337') labels = [label for label, _ in get_sca_policy_details(detection)]