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
18 changes: 17 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`/`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! |
Expand Down Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions cycode/cli/apps/scan/scan_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ def scan_command(
ScaScanTypeOption.PACKAGE_VULNERABILITIES,
ScaScanTypeOption.LICENSE_COMPLIANCE,
ScaScanTypeOption.UNMAINTAINED_PACKAGES,
ScaScanTypeOption.MALICIOUS_PACKAGES,
),
monitor: Annotated[
bool,
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 @@ -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,
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 @@ -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):
Expand Down
1 change: 1 addition & 0 deletions cycode/cli/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions cycode/cli/printers/tables/sca_table_printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand All @@ -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')
Expand Down Expand Up @@ -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'

Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
26 changes: 26 additions & 0 deletions cycode/cli/printers/utils/sca_policy_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {}
Expand All @@ -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,
}


Expand Down
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 @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
66 changes: 66 additions & 0 deletions tests/cli/printers/test_sca_table_printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'
41 changes: 41 additions & 0 deletions tests/cli/printers/test_text_printer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -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
Loading