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
44 changes: 32 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -668,15 +668,33 @@ In the previous example, if you wanted to only scan a branch named `dev`, you co
> [!NOTE]
> This option is only available to SCA scans.

We use the sbt-dependency-lock plugin to restore the lock file for SBT projects.
To disable lock restore in use `--no-restore` option.

Prerequisites:
* `sbt-dependency-lock` plugin: Install the plugin by adding the following line to `project/plugins.sbt`:

```text
addSbtPlugin("software.purpledragon" % "sbt-dependency-lock" % "1.5.1")
```
When running an SCA scan, Cycode CLI automatically attempts to restore (generate) a dependency lockfile for each supported manifest file it finds. This allows scanning transitive dependencies, not just the ones listed directly in the manifest. To skip this step and scan only direct dependencies, use the `--no-restore` flag.

The following ecosystems support automatic lockfile restoration:

| Ecosystem | Manifest file | Lockfile generated | Tool invoked (when lockfile is absent) |
|---|---|---|---|
| npm | `package.json` | `package-lock.json` | `npm install --package-lock-only --ignore-scripts --no-audit` |
| Yarn | `package.json` | `yarn.lock` | `yarn install --ignore-scripts` |
| pnpm | `package.json` | `pnpm-lock.yaml` | `pnpm install --ignore-scripts` |
| Deno | `deno.json` / `deno.jsonc` | `deno.lock` | *(read existing lockfile only)* |
| Go | `go.mod` | `go.mod.graph` | `go list -m -json all` + `go mod graph` |
| Maven | `pom.xml` | `bcde.mvndeps` | `mvn dependency:tree` |
| Gradle | `build.gradle` / `build.gradle.kts` | `gradle-dependencies-generated.txt` | `gradle dependencies -q --console plain` |
| SBT | `build.sbt` | `build.sbt.lock` | `sbt dependencyLockWrite` |
| NuGet | `*.csproj` | `packages.lock.json` | `dotnet restore --use-lock-file` |
| Ruby | `Gemfile` | `Gemfile.lock` | `bundle --quiet` |
| Poetry | `pyproject.toml` | `poetry.lock` | `poetry lock` |
| Pipenv | `Pipfile` | `Pipfile.lock` | `pipenv lock` |
| PHP Composer | `composer.json` | `composer.lock` | `composer update --no-cache --no-install --no-scripts --ignore-platform-reqs` |

If a lockfile already exists alongside the manifest, Cycode reads it directly without running any install command.

**SBT prerequisite:** The `sbt-dependency-lock` plugin must be installed. Add the following line to `project/plugins.sbt`:

```text
addSbtPlugin("software.purpledragon" % "sbt-dependency-lock" % "1.5.1")
```

### Repository Scan

Expand Down Expand Up @@ -1309,9 +1327,11 @@ For example:\

The `path` subcommand supports the following additional options:

| Option | Description |
|-------------------------|----------------------------------------------------------------------------------------------------------------------------------|
| `--maven-settings-file` | For Maven only, allows using a custom [settings.xml](https://maven.apache.org/settings.html) file when building the dependency tree |
| Option | Description |
|-----------------------------|-------------------------------------------------------------------------------------------------------------------------------------|
| `--no-restore` | Skip lockfile restoration and scan direct dependencies only. See [Lock Restore Option](#lock-restore-option) for details. |
| `--gradle-all-sub-projects` | Run the Gradle restore command for all sub-projects (use from the root of a multi-project Gradle build). |
| `--maven-settings-file` | For Maven only, allows using a custom [settings.xml](https://maven.apache.org/settings.html) file when building the dependency tree. |

# Import Command

Expand Down
25 changes: 11 additions & 14 deletions cycode/cli/apps/report/sbom/path/path_command.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import time
from pathlib import Path
from typing import Annotated, Optional
from typing import Annotated

import typer

from cycode.cli import consts
from cycode.cli.apps.report.sbom.common import create_sbom_report, send_report_feedback
from cycode.cli.apps.sca_options import (
GradleAllSubProjectsOption,
MavenSettingsFileOption,
NoRestoreOption,
apply_sca_restore_options_to_context,
)
from cycode.cli.exceptions.handle_report_sbom_errors import handle_report_exception
from cycode.cli.files_collector.path_documents import get_relevant_documents
from cycode.cli.files_collector.sca.sca_file_collector import add_sca_dependencies_tree_documents_if_needed
Expand All @@ -14,27 +20,18 @@
from cycode.cli.utils.progress_bar import SbomReportProgressBarSection
from cycode.cli.utils.scan_utils import is_cycodeignore_allowed_by_scan_config

_SCA_RICH_HELP_PANEL = 'SCA options'


def path_command(
ctx: typer.Context,
path: Annotated[
Path,
typer.Argument(exists=True, resolve_path=True, help='Path to generate SBOM report for.', show_default=False),
],
maven_settings_file: Annotated[
Optional[Path],
typer.Option(
'--maven-settings-file',
show_default=False,
help='When specified, Cycode will use this settings.xml file when building the maven dependency tree.',
dir_okay=False,
rich_help_panel=_SCA_RICH_HELP_PANEL,
),
] = None,
no_restore: NoRestoreOption = False,
gradle_all_sub_projects: GradleAllSubProjectsOption = False,
maven_settings_file: MavenSettingsFileOption = None,
) -> None:
ctx.obj['maven_settings_file'] = maven_settings_file
apply_sca_restore_options_to_context(ctx, no_restore, gradle_all_sub_projects, maven_settings_file)

client = get_report_cycode_client(ctx)
report_parameters = ctx.obj['report_parameters']
Expand Down
47 changes: 47 additions & 0 deletions cycode/cli/apps/sca_options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from pathlib import Path
from typing import Annotated, Optional

import typer

_SCA_RICH_HELP_PANEL = 'SCA options'

NoRestoreOption = Annotated[
bool,
typer.Option(
'--no-restore',
help='When specified, Cycode will not run restore command. Will scan direct dependencies [b]only[/]!',
rich_help_panel=_SCA_RICH_HELP_PANEL,
),
]

GradleAllSubProjectsOption = Annotated[
bool,
typer.Option(
'--gradle-all-sub-projects',
help='When specified, Cycode will run gradle restore command for all sub projects. '
'Should run from root project directory [b]only[/]!',
rich_help_panel=_SCA_RICH_HELP_PANEL,
),
]

MavenSettingsFileOption = Annotated[
Optional[Path],
typer.Option(
'--maven-settings-file',
show_default=False,
help='When specified, Cycode will use this settings.xml file when building the maven dependency tree.',
dir_okay=False,
rich_help_panel=_SCA_RICH_HELP_PANEL,
),
]


def apply_sca_restore_options_to_context(
ctx: typer.Context,
no_restore: bool,
gradle_all_sub_projects: bool,
maven_settings_file: Optional[Path],
) -> None:
ctx.obj['no_restore'] = no_restore
ctx.obj['gradle_all_sub_projects'] = gradle_all_sub_projects
ctx.obj['maven_settings_file'] = maven_settings_file
40 changes: 10 additions & 30 deletions cycode/cli/apps/scan/scan_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
import click
import typer

from cycode.cli.apps.sca_options import (
GradleAllSubProjectsOption,
MavenSettingsFileOption,
NoRestoreOption,
apply_sca_restore_options_to_context,
)
from cycode.cli.apps.scan.remote_url_resolver import _try_get_git_remote_url
from cycode.cli.cli_types import ExportTypeOption, ScanTypeOption, ScaScanTypeOption, SeverityOption
from cycode.cli.consts import (
Expand Down Expand Up @@ -72,33 +78,9 @@ def scan_command(
rich_help_panel=_SCA_RICH_HELP_PANEL,
),
] = False,
no_restore: Annotated[
bool,
typer.Option(
'--no-restore',
help='When specified, Cycode will not run restore command. Will scan direct dependencies [b]only[/]!',
rich_help_panel=_SCA_RICH_HELP_PANEL,
),
] = False,
gradle_all_sub_projects: Annotated[
bool,
typer.Option(
'--gradle-all-sub-projects',
help='When specified, Cycode will run gradle restore command for all sub projects. '
'Should run from root project directory [b]only[/]!',
rich_help_panel=_SCA_RICH_HELP_PANEL,
),
] = False,
maven_settings_file: Annotated[
Optional[Path],
typer.Option(
'--maven-settings-file',
show_default=False,
help='When specified, Cycode will use this settings.xml file when building the maven dependency tree.',
dir_okay=False,
rich_help_panel=_SCA_RICH_HELP_PANEL,
),
] = None,
no_restore: NoRestoreOption = False,
gradle_all_sub_projects: GradleAllSubProjectsOption = False,
maven_settings_file: MavenSettingsFileOption = None,
export_type: Annotated[
ExportTypeOption,
typer.Option(
Expand Down Expand Up @@ -152,10 +134,8 @@ def scan_command(
ctx.obj['sync'] = sync
ctx.obj['severity_threshold'] = severity_threshold
ctx.obj['monitor'] = monitor
ctx.obj['maven_settings_file'] = maven_settings_file
ctx.obj['report'] = report
ctx.obj['gradle_all_sub_projects'] = gradle_all_sub_projects
ctx.obj['no_restore'] = no_restore
apply_sca_restore_options_to_context(ctx, no_restore, gradle_all_sub_projects, maven_settings_file)

scan_client = get_scan_cycode_client(ctx)
ctx.obj['client'] = scan_client
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 @@ -46,6 +46,7 @@ class SbomFormatOption(StrEnum):
SPDX_2_2 = 'spdx-2.2'
SPDX_2_3 = 'spdx-2.3'
CYCLONEDX_1_4 = 'cyclonedx-1.4'
CYCLONEDX_1_6 = 'cyclonedx-1.6'


class SbomOutputFormatOption(StrEnum):
Expand Down
32 changes: 28 additions & 4 deletions cycode/cli/files_collector/sca/base_restore_dependencies.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import os
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Optional

import typer
Expand Down Expand Up @@ -32,6 +32,9 @@ def execute_commands(
},
)

if not commands:
return None

try:
outputs = []

Expand Down Expand Up @@ -106,22 +109,43 @@ def try_restore_dependencies(self, document: Document) -> Optional[Document]:
)
return Document(relative_restore_file_path, restore_file_content, self.is_git_diff)

def get_manifest_dir(self, document: Document) -> Optional[str]:
"""Return the directory containing the manifest file, resolving monitor-mode paths.

Uses the same path resolution as get_manifest_file_path() to ensure consistency.
Falls back to document.absolute_path when the resolved manifest path is ambiguous.
"""
manifest_file_path = self.get_manifest_file_path(document)
if manifest_file_path:
parent = Path(manifest_file_path).parent
# Skip '.' (no parent) and filesystem root (its own parent)
if parent != Path('.') and parent != parent.parent:
return str(parent)

base = document.absolute_path or document.path
if base:
parent = Path(base).parent
if parent != Path('.') and parent != parent.parent:
return str(parent)

return None

def get_working_directory(self, document: Document) -> Optional[str]:
return os.path.dirname(document.absolute_path)
return str(Path(document.absolute_path).parent)

def get_restored_lock_file_name(self, restore_file_path: str) -> str:
return self.get_lock_file_name()

def get_any_restore_file_already_exist(self, document: Document, restore_file_paths: list[str]) -> str:
for restore_file_path in restore_file_paths:
if os.path.isfile(restore_file_path):
if Path(restore_file_path).is_file():
return restore_file_path

return build_dep_tree_path(document.absolute_path, self.get_lock_file_name())

@staticmethod
def verify_restore_file_already_exist(restore_file_path: str) -> bool:
return os.path.isfile(restore_file_path)
return Path(restore_file_path).is_file()

@abstractmethod
def is_project(self, document: Document) -> bool:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import os
from pathlib import Path
from typing import Optional

import typer
Expand All @@ -20,13 +20,13 @@ def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int)
super().__init__(ctx, is_git_diff, command_timeout, create_output_file_manually=True)

def try_restore_dependencies(self, document: Document) -> Optional[Document]:
manifest_exists = os.path.isfile(self.get_working_directory(document) + os.sep + BUILD_GO_FILE_NAME)
lock_exists = os.path.isfile(self.get_working_directory(document) + os.sep + BUILD_GO_LOCK_FILE_NAME)
manifest_exists = (Path(self.get_working_directory(document)) / BUILD_GO_FILE_NAME).is_file()
lock_exists = (Path(self.get_working_directory(document)) / BUILD_GO_LOCK_FILE_NAME).is_file()

if not manifest_exists or not lock_exists:
logger.info('No manifest go.mod file found' if not manifest_exists else 'No manifest go.sum file found')

manifest_files_exists = manifest_exists & lock_exists
manifest_files_exists = manifest_exists and lock_exists

if not manifest_files_exists:
return None
Expand Down
46 changes: 46 additions & 0 deletions cycode/cli/files_collector/sca/npm/restore_deno_dependencies.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from pathlib import Path
from typing import Optional

import typer

from cycode.cli.files_collector.sca.base_restore_dependencies import BaseRestoreDependencies, build_dep_tree_path
from cycode.cli.models import Document
from cycode.cli.utils.path_utils import get_file_content
from cycode.logger import get_logger

logger = get_logger('Deno Restore Dependencies')

DENO_MANIFEST_FILE_NAMES = ('deno.json', 'deno.jsonc')
DENO_LOCK_FILE_NAME = 'deno.lock'


class RestoreDenoDependencies(BaseRestoreDependencies):
def __init__(self, ctx: typer.Context, is_git_diff: bool, command_timeout: int) -> None:
super().__init__(ctx, is_git_diff, command_timeout)

def is_project(self, document: Document) -> bool:
return Path(document.path).name in DENO_MANIFEST_FILE_NAMES

def try_restore_dependencies(self, document: Document) -> Optional[Document]:
manifest_dir = self.get_manifest_dir(document)
if not manifest_dir:
return None

lockfile_path = Path(manifest_dir) / DENO_LOCK_FILE_NAME
if not lockfile_path.is_file():
logger.debug('No deno.lock found alongside deno.json, skipping deno restore, %s', {'path': document.path})
return None

content = get_file_content(str(lockfile_path))
relative_path = build_dep_tree_path(document.path, DENO_LOCK_FILE_NAME)
logger.debug('Using existing deno.lock, %s', {'path': str(lockfile_path)})
return Document(relative_path, content, self.is_git_diff)

def get_commands(self, manifest_file_path: str) -> list[list[str]]:
return []

def get_lock_file_name(self) -> str:
return DENO_LOCK_FILE_NAME

def get_lock_file_names(self) -> list[str]:
return [DENO_LOCK_FILE_NAME]
Loading