Skip to content
Merged
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 @@ -784,7 +784,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 @@ -876,6 +876,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 @@ -15,6 +15,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 @@ -353,6 +353,7 @@ def _detect_operating_system() -> OperatingSystem:
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:
Comment thread
omer-roth marked this conversation as resolved.
Resolved
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
21 changes: 21 additions & 0 deletions cycode/cli/printers/utils/sca_policy_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,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 Down Expand Up @@ -40,10 +41,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', detection_details.get('remediation_guidelines') 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,
MALICIOUS_PACKAGE_POLICY_ID: _malicious_package_details,
}


Expand Down
17 changes: 17 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,18 @@ 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_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
56 changes: 56 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,57 @@ 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_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'
19 changes: 19 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,21 @@ 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',
remediation_guidelines='Remove this package from your dependencies.',
)

result = _render(printer, output, detection)

assert 'Advisory: MAL-2024-1234' in result
assert 'Remediation: Remove this package from your dependencies.' in result
64 changes: 63 additions & 1 deletion tests/cli/printers/utils/test_sca_policy_details.py
Original file line number Diff line number Diff line change
@@ -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,
)
Expand Down Expand Up @@ -79,13 +80,74 @@ def test_unmaintained_package_without_a_scorecard() -> None:
]


REMEDIATION = 'Remove this package from your dependencies.'


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',
remediation_guidelines=REMEDIATION,
)

assert get_sca_policy_details(detection) == [
('Advisory', '[link=https://osv.dev/vulnerability/MAL-2024-1234]MAL-2024-1234[/]'),
('Remediation', 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', remediation_guidelines=REMEDIATION
)

assert get_sca_policy_details(detection) == [
('Advisory', 'MAL-2024-1234'),
('Remediation', 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',
remediation_guidelines=REMEDIATION,
)

assert get_sca_policy_details(detection) == [
('Advisory', 'N/A'),
('Remediation', REMEDIATION),
]


def test_malicious_package_without_remediation_guidelines() -> None:
detection = _make_detection(MALICIOUS_PACKAGE_POLICY_ID, threat_id='MAL-2024-1234')

assert get_sca_policy_details(detection) == [
('Advisory', 'MAL-2024-1234'),
('Remediation', 'N/A'),
]


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)]
Expand Down
Loading