Commit 5a3b6760 authored by Pierre Smeyers's avatar Pierre Smeyers
Browse files

Merge branch 'feat/support-merge' into 'main'

support SBOM merge

See merge request to-be-continuous/tools/dt-sbom-scanner!5
parents da9b8b27 86ebf6d1
Loading
Loading
Loading
Loading
+16 −8
Original line number Diff line number Diff line
@@ -41,7 +41,7 @@ docker run \
## Usage

```bash
usage: sbom-scanner [-h] [-u BASE_API_URL] [-k API_KEY] [-p PROJECT_PATH] [-s PATH_SEPARATOR] [-i] [sbom_patterns ...]
usage: sbom-scanner [-h] [-u BASE_API_URL] [-k API_KEY] [-p PROJECT_PATH] [-s PATH_SEPARATOR] [-m] [-o MERGE_OUTPUT] [-i] [--purl-max-len PURL_MAX_LEN] [sbom_patterns ...]

This tool scans for SBOM files and publishes them to a Dependency Track server.

@@ -58,7 +58,12 @@ options:
                        Dependency Track target project path to publish SBOM files to (see doc)
  -s PATH_SEPARATOR, --path-separator PATH_SEPARATOR
                        Separator to use in project path (default: '/')
  -m, --merge           Merge all SBOM files into one
  -o MERGE_OUTPUT, --merge-output MERGE_OUTPUT
                        Output merged SBOM file (only used with merge enabled) - for debugging purpose
  -i, --insecure        Skip SSL verification
  -l PURL_MAX_LEN, --purl-max-len PURL_MAX_LEN
                        PURLs max length (-1: auto, 0: no trim, >0: trim to size - default: -1)
```

### Arguments
@@ -70,11 +75,14 @@ If none is specified, the program will look for SBOM files matching `**/*.cyclon
### Options

| CLI option                | Env. Variable              | Description                                                                     |
| ------------------------- | -------------------------- | ----------------------------------------------------------------------------- |
| ------------------------- | -------------------------- | ------------------------------------------------------------------------------- |
| `-u` / `--base-api-url`   | `$DEPTRACK_BASE_API_URL`   | Dependency Track server base API url (includes `/api`) (**mandatory**)          |
| `-k` / `--api-key`        | `$DEPTRACK_API_KEY`        | Dependency Track API key (**mandatory**)                                        |
| `-p` / `--project-path`   | `$DEPTRACK_PROJECT_PATH`   | Dependency Track target project path to publish SBOM files to (**mandatory**)   |
| `-s` / `--path-separator` | `$DEPTRACK_PATH_SEPARATOR` | Separator to use in project path (default `/`)                                  |
| `-m` / `--merge`          | `$DEPTRACK_MERGE`          | Merge all SBOM files into one (default `false`)                                 |
| `-o` / `--merge-output`   | `$DEPTRACK_MERGE_OUTPUT`   | Output merged SBOM file (only used with merge enabled) - for debugging purpose  |
| `-l` / `--purl-max-len`   | `$DEPTRACK_PURL_MAX_LEN`   | PURLs max length (`-1`: auto, `0`: no trim, `>0`: trim to size - default: `-1`) |
| `-i` / `--insecure`       | `$DEPTRACK_INSECURE`       | Skip SSL verification                                                           |

## API Key permissions
+164 −60

File changed.

Preview size limit exceeded, changes collapsed.

+1 −0
Original line number Diff line number Diff line
@@ -17,6 +17,7 @@ sbom-scanner = "sbom_scanner.scan:run"
[tool.poetry.dependencies]
python = "^3.11"
requests = "^2.32.2"
cyclonedx-python-lib = "^7.4.0"

[tool.poetry.group.dev.dependencies]
# new development dependencies can be added with 'poetry add -D yyy'
+22 −0
Original line number Diff line number Diff line
class AnsiColors:
    BLACK = "\033[0;30m"
    RED = "\033[0;31m"
    GREEN = "\033[0;32m"
    YELLOW = "\033[0;33m"
    BLUE = "\033[0;34m"
    PURPLE = "\033[0;35m"
    CYAN = "\033[0;36m"
    WHITE = "\033[0;37m"

    HGRAY = "\033[90m"
    HRED = "\033[91m"
    HGREEN = "\033[92m"
    HYELLOW = "\033[93m"
    HBLUE = "\033[94m"
    HPURPLE = "\033[95m"
    HCYAN = "\033[96m"
    HWHITE = "\033[97m"

    RESET = "\033[0m"
    BOLD = "\033[1m"
    UNDERLINE = "\033[4m"
 No newline at end of file
+150 −0
Original line number Diff line number Diff line
import json
from pathlib import Path
from typing import Optional
from warnings import catch_warnings

from cyclonedx.model.bom import Bom
from cyclonedx.model.component import Component
from cyclonedx.output import OutputFormat, make_outputter
from cyclonedx.schema import SchemaVersion

from sbom_scanner.AnsiColors import AnsiColors


def load_bom(file: Path) -> Bom:
    """
    Loads SBOM from file
    """
    # NOTE: This is a hack to fix missing bom_ref in Component
    component_init = Component.__init__

    def component_patched(self, **kwargs):
        if "bom_ref" not in kwargs:
            print(f"{AnsiColors.YELLOW}{AnsiColors.RESET} missing 'bom_ref' in component {AnsiColors.HGRAY}{kwargs.get('name')}@{kwargs.get('version')}{AnsiColors.RESET} ({kwargs['type'].value}): fix")
            kwargs["bom_ref"] = kwargs["name"]
        component_init(self, **kwargs)

    Component.__init__ = component_patched

    try:
        with catch_warnings(record=True) as warnings:
            if file.suffix == ".xml":
                with open(file) as reader:
                    bom = Bom.from_xml(reader)
            else:
                with open(file) as reader:
                    # NOTE: This is a hack to remove conflicting metadata
                    # https://github.com/CycloneDX/cyclonedx-python-lib/issues/578
                    raw_json = json.load(reader)
                    raw_json["metadata"].pop("tools", None)
                    raw_json["metadata"].pop("lifecycles", None)
                    bom = Bom.from_json(raw_json)

            # Restore original method
            Component.__init__ = component_init

            bom.validate()

        if warnings:
            for w in warnings:
                print(f"{AnsiColors.YELLOW}{AnsiColors.RESET} l#{w.lineno}: {w.message}")
    except Exception as e:
        raise ValueError(f"Error while loading SBOM: {file.name}") from e

    return bom


def trim_purls(sbom: Bom, limit: int = 0) -> None:
    """Tries to trim PURLs by removing longest qualifiers"""
    if limit <= 0:
        return

    for component in sbom.components:
        purl = component.purl
        if len(str(purl)) < limit:
            continue
        purl_orig = str(purl)

        purl_trunc = purl_orig
        while purl.qualifiers and len(purl_trunc) >= limit:
            longest_key = max(purl.qualifiers, key=lambda key: len(purl.qualifiers[key]))
            purl.qualifiers.pop(longest_key)
            purl_trunc = str(purl)

        if len(str(purl)) >= limit:
            print(f"{AnsiColors.YELLOW}{AnsiColors.RESET} trimmed {purl_orig} -> {AnsiColors.HGRAY}{purl_trunc}{AnsiColors.RESET} but still exceeds limit ({limit})")
        else:
            print(f"{AnsiColors.GREEN}{AnsiColors.RESET} successfully trimmed {purl_orig} -> {AnsiColors.HGRAY}{purl_trunc}{AnsiColors.RESET}")


def serialize(bom: Bom, format=OutputFormat.JSON, schema_version=SchemaVersion.V1_5) -> str:
    return make_outputter(bom, format, schema_version).output_as_string()


def to_json(bom: Bom, schema_version=SchemaVersion.V1_5) -> str:
    return make_outputter(bom, OutputFormat.JSON, schema_version).output_as_string()


def to_xml(bom: Bom, schema_version=SchemaVersion.V1_5) -> str:
    return make_outputter(bom, OutputFormat.XML, schema_version).output_as_string()


def save_bom(bom: Bom, file: Path, schema_version=SchemaVersion.V1_5) -> None:
    return make_outputter(
        bom,
        OutputFormat.XML if file.suffix == ".xml" else OutputFormat.JSON,
        schema_version,
    ).output_to_file(file, allow_overwrite=True)


# def cleanup(self, file: TextIO) -> str:
#     """Cleans up a single SBOM for import into Dependency-Track"""
#     bom = self.load(file)
#     return self.output(bom)


def merge_boms(
    root_name: str,
    root_version: Optional[str],
    root_group: Optional[str],
    boms: list[Bom],
) -> Bom:
    """Merges multiple SBOMs into a single SBOM"""
    merged = Bom()
    root = merged.metadata.component = Component(
        name=root_name, version=root_version, group=root_group
    )

    for bom in boms:
        merged.metadata.authors.update(bom.metadata.authors)

        merged.services.update(bom.services)
        merged.vulnerabilities.update(bom.vulnerabilities)

        depended = set()
        for dependency in bom.dependencies:
            if dependency.dependencies:
                merged.register_dependency(
                    Component(name=dependency.ref.value, bom_ref=dependency.ref),
                    [
                        Component(name=d.ref.value, bom_ref=d.ref)
                        for d in dependency.dependencies
                    ],
                )
                depended.update(d.ref for d in dependency.dependencies)

        def add_component(component: Component, parent: Optional[Component]):
            if all(c.bom_ref != component.bom_ref for c in merged.components):
                merged.components.add(component)
            if parent and component.bom_ref not in depended:
                merged.register_dependency(parent, [component])
            for child in component.components:
                add_component(child, component)
            component.components.clear()

        if bom.metadata.component:
            add_component(bom.metadata.component, root)
        for component in bom.components:
            add_component(component, bom.metadata.component)

    return merged
Loading