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
208 changes: 205 additions & 3 deletions README.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions cycode/cli/apps/report/sbom/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import typer

from cycode.cli.apps.report.sbom.binary.binary_command import binary_command
from cycode.cli.apps.report.sbom.path.path_command import path_command
from cycode.cli.apps.report.sbom.repository_url.repository_url_command import repository_url_command
from cycode.cli.apps.report.sbom.sbom_command import sbom_command
Expand All @@ -10,6 +11,7 @@
app.command(name='repository-url', short_help='Generate SBOM report for provided repository URI in the command.')(
repository_url_command
)
app.command(name='binary', short_help='Generate SBOM report for a built Java artifact (JAR, WAR, EAR).')(binary_command)

# backward compatibility
app.command(hidden=True, name='repository_url')(repository_url_command)
Empty file.
156 changes: 156 additions & 0 deletions cycode/cli/apps/report/sbom/binary/binary_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import time
from pathlib import Path
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.exceptions.handle_report_sbom_errors import handle_report_exception
from cycode.cli.files_collector.binary.collector import collect_binary_documents
from cycode.cli.files_collector.zip_documents import zip_documents
from cycode.cli.utils.get_api_client import get_report_cycode_client
from cycode.cli.utils.progress_bar import SbomReportProgressBarSection

_REPORT_COMMAND_TYPE = 'binary'


def binary_command(
ctx: typer.Context,
path: Annotated[
Path,
typer.Argument(
exists=True,
resolve_path=True,
help='Path to the built artifact to generate an SBOM for.',
show_default=False,
),
],
max_depth: Annotated[
int,
typer.Option('--max-depth', help='Nested-archive recursion limit.', min=1),
] = consts.BINARY_MAX_DEPTH,
maven_central: Annotated[
bool,
typer.Option(
'--maven-central',
help='Consult Maven Central: look archives that embedded metadata cannot identify up by SHA-1, and '
'with --include-declared fetch parent poms to pin declared versions. Sends digests and public '
'coordinates, never the archive itself.',
),
] = False,
include_declared: Annotated[
bool,
typer.Option(
'--include-declared',
help='Also include the compile- and runtime-scope dependencies that embedded pom.xml files declare '
'but the artifact does not ship, marked as declared rather than shipped. '
'Versions managed by a parent pom need --maven-central to resolve.',
),
] = False,
include_test_scope: Annotated[
bool,
typer.Option(
'--include-test-scope',
help='With --include-declared: also include test-, provided- and system-scope declarations, which '
'Maven never passes to a consuming build. Each component still records its scope.',
),
] = False,
include_transitive: Annotated[
bool,
typer.Option(
'--include-transitive',
help='With --include-declared and --maven-central: follow each declared dependency to the '
'dependencies its own pom declares, the way a Maven build would, and include those too.',
),
] = False,
) -> None:
""":package: [bold cyan]Generate an SBOM for a built Java artifact.[/]

Reads a JAR, WAR, EAR or Spring Boot fat JAR and produces an SBOM of the open-source components inside it,
without scanning them for vulnerabilities. Answers the compliance case directly: an SBOM of what shipped,
rather than of what was committed.

Example usage:
* `cycode report sbom --format cyclonedx-1.4-json binary app.war`
* `cycode report sbom --format spdx-2.3-json binary app.ear`

Format conversion happens server-side, so every format the path command supports is supported here too.

"""
if include_test_scope and not include_declared:
raise typer.BadParameter(
'--include-test-scope widens what --include-declared reports; it does nothing on its own.',
param_hint='--include-test-scope',
)
if include_transitive and not (include_declared and maven_central):
raise typer.BadParameter(
'--include-transitive follows declared dependencies through their poms on Maven Central; '
'it needs both --include-declared and --maven-central.',
param_hint='--include-transitive',
)

ctx.obj['binary_max_depth'] = max_depth
ctx.obj['maven_central'] = maven_central
ctx.obj['include_declared'] = include_declared
ctx.obj['include_test_scope'] = include_test_scope
ctx.obj['include_transitive'] = include_transitive

client = get_report_cycode_client(ctx)
report_parameters = ctx.obj['report_parameters']
output_format = report_parameters.output_format
output_file = ctx.obj['output_file']

progress_bar = ctx.obj['progress_bar']
progress_bar.start()

start_scan_time = time.time()
report_execution_id = -1

try:
# the only difference from the path command: our collector in place of the manifest walk. Everything from
# zip_documents onward is reused verbatim, and the server generates the document.
collection = collect_binary_documents(
ctx,
(str(path),),
stop_on_error=ctx.obj.get('stop_on_error', False),
progress_bar_section=SbomReportProgressBarSection.PREPARE_LOCAL_FILES,
)
ctx.obj['binary_result'] = collection

if not collection.documents:
raise typer.BadParameter(
f'No supported binary artifacts were found at {str(path)!r}. '
'Supported artifacts are .jar, .war and .ear files.',
param_hint='PATH',
)

zipped_documents = zip_documents(consts.SCA_SCAN_TYPE, collection.documents)
report_execution = client.request_sbom_report_execution(report_parameters, zip_file=zipped_documents)
report_execution_id = report_execution.id

create_sbom_report(progress_bar, client, report_execution_id, output_file, output_format)

send_report_feedback(
client=client,
start_scan_time=start_scan_time,
report_type='SBOM',
report_command_type=_REPORT_COMMAND_TYPE,
request_report_parameters=report_parameters.to_dict(without_entity_type=False),
report_execution_id=report_execution_id,
request_zip_file_size=zipped_documents.size,
)
except Exception as e:
progress_bar.stop()

send_report_feedback(
client=client,
start_scan_time=start_scan_time,
report_type='SBOM',
report_command_type=_REPORT_COMMAND_TYPE,
request_report_parameters=report_parameters.to_dict(without_entity_type=False),
report_execution_id=report_execution_id,
error_message=str(e),
)

handle_report_exception(ctx, e)
2 changes: 2 additions & 0 deletions cycode/cli/apps/scan/__init__.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import typer

from cycode.cli.apps.scan.binary.binary_command import binary_command
from cycode.cli.apps.scan.commit_history.commit_history_command import commit_history_command
from cycode.cli.apps.scan.path.path_command import path_command
from cycode.cli.apps.scan.pre_commit.pre_commit_command import pre_commit_command
Expand All @@ -23,6 +24,7 @@

app.command(name='path', short_help='Scan the files in the paths provided in the command.')(path_command)
app.command(name='repository', short_help='Scan the Git repository included files.')(repository_command)
app.command(name='binary', short_help='Scan built Java artifacts (JAR, WAR, EAR, Spring Boot).')(binary_command)
app.command(name='commit-history', short_help='Scan commit history or perform diff scanning between specific commits.')(
commit_history_command
)
Expand Down
Empty file.
58 changes: 58 additions & 0 deletions cycode/cli/apps/scan/binary/binary_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from pathlib import Path
from typing import Annotated

import typer

from cycode.cli.apps.scan.binary.identity import (
assert_monitor_has_an_explicit_identity,
resolve_platform_identity,
)
from cycode.cli.apps.scan.code_scanner import scan_binary_artifacts
from cycode.cli.logger import logger


def binary_command(
ctx: typer.Context,
paths: Annotated[
list[Path],
typer.Argument(
exists=True,
resolve_path=True,
help='Paths to the built artifacts to scan',
show_default=False,
),
],
) -> None:
""":package: [bold cyan]Scan built Java artifacts for open-source vulnerabilities.[/]

Opens a JAR, WAR, EAR or Spring Boot fat JAR, identifies the open-source components inside it, and scans them
exactly as a source scan would. The artifact never leaves your machine: only the component inventory is
uploaded.

Example usage:
* `cycode scan -t sca binary app.war`: Scan a single deployable.
* `cycode scan -t sca binary dist/`: Scan every Java archive under a directory.
* `cycode scan -t sca --max-depth 5 binary app.ear`: Recurse further into nested archives.

Components are identified from embedded Maven metadata. Anything that cannot be identified is reported in its
own section rather than guessed at. Relocated and shaded classes are not detected: where source is available,
a source scan gives a truer dependency graph.

"""
tuple_paths = tuple(str(path) for path in paths)

identity = resolve_platform_identity(ctx, tuple_paths)
if ctx.obj.get('monitor'):
assert_monitor_has_an_explicit_identity(identity)

ctx.obj['binary_identity'] = identity

progress_bar = ctx.obj['progress_bar']
progress_bar.start()

logger.debug(
'Starting binary scan process, %s',
{'paths': paths, 'identity': identity.value, 'identity_source': identity.source},
)

scan_binary_artifacts(ctx, tuple_paths)
66 changes: 66 additions & 0 deletions cycode/cli/apps/scan/binary/identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""What the platform files a binary scan under.

Identity lives with the command rather than with the collector: it is a question about how results are recorded,
not about how an archive is read, and keeping it here leaves the collector free of any dependency on the scan app.
"""

import os
from dataclasses import dataclass

import typer

from cycode.cli.apps.scan.remote_url_resolver import get_remote_url_scan_parameter
from cycode.cli.files_collector.binary.collector import find_supported_artifacts

IDENTITY_FROM_PROJECT_NAME = 'project-name'
IDENTITY_FROM_GIT_REMOTE = 'git-remote'
IDENTITY_FROM_FILENAME = 'filename'


@dataclass(frozen=True)
class PlatformIdentity:
"""What the platform will file these results under, and where that came from."""

value: str
source: str

@property
def is_explicit(self) -> bool:
"""True when a human or a repository named this project, rather than it being taken off a filename."""
return self.source in (IDENTITY_FROM_PROJECT_NAME, IDENTITY_FROM_GIT_REMOTE)


def resolve_platform_identity(ctx: typer.Context, paths: tuple[str, ...]) -> PlatformIdentity:
"""Inside a repository, results attach to that repository exactly as a path scan does.

Detached from one, the artifact filename is the identity, which is fine for a one-off assessment and is
explicitly not fine for monitoring.
"""
project_name = ctx.obj.get('project_name')
if project_name:
return PlatformIdentity(value=project_name, source=IDENTITY_FROM_PROJECT_NAME)

remote_url = get_remote_url_scan_parameter(paths)
if remote_url:
return PlatformIdentity(value=remote_url, source=IDENTITY_FROM_GIT_REMOTE)

artifacts = find_supported_artifacts(paths)
filename = os.path.basename(artifacts[0]) if artifacts else os.path.basename(paths[0])
return PlatformIdentity(value=filename, source=IDENTITY_FROM_FILENAME)


def assert_monitor_has_an_explicit_identity(identity: PlatformIdentity) -> None:
"""Refuse --monitor on a bare filename identity.

Monitoring keyed on `app.jar` would merge unrelated teams into one project and quietly corrupt the trend data,
which is worse than refusing: the damage is invisible until someone acts on the numbers.
"""
if identity.is_explicit:
return

raise typer.BadParameter(
f'--monitor needs an explicit project identity, but the only identity available is the artifact filename '
f'({identity.value!r}). Run from inside the Git repository this artifact was built from, or pass '
f'--project-name to name the project yourself.',
param_hint='--monitor',
)
Loading