Commit d5dbfa31 authored by Pierre Smeyers's avatar Pierre Smeyers
Browse files

Merge branch 'main' into 'main'

feat(scanner): add isLatest flag support for projects in Dependency Track (DT >= 4.12.0)

Closes #9

See merge request to-be-continuous/tools/dt-sbom-scanner!90
parents 06b6c676 bd7184e2
Loading
Loading
Loading
Loading
+5 −1
Original line number Diff line number Diff line
@@ -50,7 +50,7 @@ docker run \

```bash
usage: sbom-scanner [-h] [-u BASE_API_URL] [-k API_KEY] [-i] [-p PROJECT_PATH] [-s PATH_SEPARATOR] [-t TAGS] [--parent-collection-logic {NONE,ALL,TAG,LATEST}]
                    [--parent-collection-logic-tag PARENT_COLLECTION_LOGIC_TAG] [-m] [-o MERGE_OUTPUT] [-l PURL_MAX_LEN] [-U] [-V MERGED_VEX_FILE] [-S] [-R RISK_SCORE_THRESHOLD]
                    [--parent-collection-logic-tag PARENT_COLLECTION_LOGIC_TAG] [--latest-depth LATEST_DEPTH] [-m] [-o MERGE_OUTPUT] [-l PURL_MAX_LEN] [-U] [-V MERGED_VEX_FILE] [-S] [-R RISK_SCORE_THRESHOLD]
                    [sbom_patterns ...]

This tool scans for SBOM files and publishes them to a Dependency Track server.
@@ -78,6 +78,8 @@ Project settings:
                        Set up how the parent aggregates its direct children (ALL: all, TAG: with tag matching --parent-collection-logic-tag, LATEST: flagged as latest, NONE: disable), default is ALL (DT version >= 4.13.0)
  --parent-collection-logic-tag PARENT_COLLECTION_LOGIC_TAG
                        Tag for aggregation if --parent-collection-logic is set to TAG
  --latest-depth LATEST_DEPTH
                        Number of trailing project path elements to mark as LATEST (defaults to 1 - _only the leaf element_).<br/>_Only supported on DT >= 4.12.0_

SBOM management:
  -m, --merge           Merge all SBOM files into one
@@ -124,6 +126,8 @@ If none is specified, the program will look for SBOM files matching `**/*.cyclon
| `-t` / `--tags`                 | `$DEPTRACK_TAGS`                        | Comma separated list of tags to put in the project in `autoCreate` mode        |
| `--parent-collection-logic`     | `$DEPTRACK_PARENT_COLLECTION_LOGIC`     | Set up how the parent aggregates its direct children (see doc), default is ALL |
| `--parent-collection-logic-tag` | `$DEPTRACK_PARENT_COLLECTION_LOGIC_TAG` | Tag for aggregation if `--parent-collection-logic` is set to `TAG`             |
| `--latest-depth`                | `$DEPTRACK_LATEST_DEPTH`                | Number of trailing project path elements to mark as LATEST (defaults to 1 - _only the leaf element_).<br/>_Only supported on DT >= 4.12.0_ |


#### SBOM options

+121 −16
Original line number Diff line number Diff line
@@ -11,7 +11,7 @@ from functools import cache
from logging import Logger
from pathlib import Path
from time import sleep
from typing import Optional
from typing import Any, Optional

import requests
from cyclonedx.model.bom import Bom
@@ -133,7 +133,7 @@ class DtProjectDef:
        return self.definition.split("@")[1] if "@" in self.definition else None

    @property
    def params(self) -> dict[str, str]:
    def params(self) -> dict[str, Any]:
        params = {}
        if self.is_uuid:
            # target project definition is a UUID: nothing more is required
@@ -288,6 +288,7 @@ class Scanner:
        parent_collection_logic_tag: str = "",
        upload_vex: bool = False,
        merged_vex_file=None,
        latest_depth: int = 1,
        **_: None,
    ):
        self.api_client = ApiClient(base_api_url, api_key, verify_ssl)
@@ -301,6 +302,7 @@ class Scanner:
        self.tags = list(filter(None, map(str.strip, tags.split(",")))) if tags else []
        self.parent_collection_logic = parent_collection_logic
        self.parent_collection_logic_tag = parent_collection_logic_tag
        self.latest_depth = latest_depth
        self.sbom_count = 0
        self.sbom_scan_failed = 0
        self.upload_vex = upload_vex
@@ -353,11 +355,30 @@ class Scanner:
    def has_permission(self, perm: DtPermission) -> bool:
        return perm in self.get_permissions()

    def _set_project_latest(self, project_data: dict[str, Any]):
        """Sets the isLatest flag on a project using a safe POST update."""
        print(f"  - update {AnsiColors.YELLOW}isLatest{AnsiColors.RESET} flag...")
        # Use POST with full object to avoid PATCH side effects on primitive booleans (active/isLatest)
        # https://github.com/DependencyTrack/dependency-track/issues/5279
        updated_data = project_data.copy()
        updated_data["isLatest"] = True
        self.api_client.post(
            "/v1/project",
            headers={
                "content-type": MIME_APPLICATION_JSON,
            },
            json=updated_data,
        ).raise_for_status()

    # rewinds the given project path and creates a DT project for each non-UUID defined project
    # returns the tail project UUID
    @cache
    def get_or_create_project(
        self, project_path: str, classifier="application", is_parent: bool = False
        self,
        project_path: str,
        remaining_latest_depth: int,
        classifier="application",
        is_parent: bool = False,
    ) -> str:
        project_path_parts = project_path.split(self.path_separator)
        project_def = DtProjectDef(project_path_parts[-1])
@@ -388,6 +409,22 @@ class Scanner:
            print(
                f"- {AnsiColors.YELLOW}{project_path}{AnsiColors.RESET} found (by name/version): {exact_match['uuid']}..."
            )
            # update isLatest if needed
            if (
                self.dt_version >= Version("4.12.0")
                and remaining_latest_depth > 0
                and not exact_match.get("isLatest")
            ):
                self._set_project_latest(exact_match)

            # if level > 1, we must also ensure parents are latest
            if remaining_latest_depth > 1 and len(project_path_parts) > 1:
                self.get_or_create_project(
                    self.path_separator.join(project_path_parts[:-1]),
                    remaining_latest_depth=remaining_latest_depth - 1,
                    classifier=classifier,
                    is_parent=True,
                )
            return exact_match["uuid"]
        # if project exists but not the version, we have to CLONE it
        name_match = next(
@@ -402,12 +439,7 @@ class Scanner:
                f"- {AnsiColors.YELLOW}{project_path}{AnsiColors.RESET} found sibling (version: {name_match.get('version')}): {name_match['uuid']}..."
            )
            # now create a clone of the project
            resp = self.api_client.put(
                "/v1/project/clone",
                headers={
                    "content-type": MIME_APPLICATION_JSON,
                },
                json={
            clone_data = {
                "project": name_match["uuid"],
                "version": project_def.version,
                "includeTags": True,
@@ -416,7 +448,17 @@ class Scanner:
                "includeServices": True,
                "includeAuditHistory": True,
                "includeACL": True,
            }
            # Include makeCloneLatest only if DT version supports it (>= 4.12.0) and current level is > 0
            if self.dt_version >= Version("4.12.0") and remaining_latest_depth > 0:
                clone_data["makeCloneLatest"] = True
            
            resp = self.api_client.put(
                "/v1/project/clone",
                headers={
                    "content-type": MIME_APPLICATION_JSON,
                },
                json=clone_data,
            )
            try:
                resp.raise_for_status()
@@ -434,6 +476,14 @@ class Scanner:
                print(
                    f"- {AnsiColors.YELLOW}{project_path}{AnsiColors.RESET} {AnsiColors.HGREEN}successfully{AnsiColors.RESET} cloned (from sibling): {created_uuid}"
                )
                # if level > 1, we must also ensure parents are latest
                if remaining_latest_depth > 1 and len(project_path_parts) > 1:
                    self.get_or_create_project(
                        self.path_separator.join(project_path_parts[:-1]),
                        remaining_latest_depth=remaining_latest_depth - 1,
                        classifier=classifier,
                        is_parent=True,
                    )
                return created_uuid
            except requests.exceptions.HTTPError as he:
                print(
@@ -449,6 +499,10 @@ class Scanner:
            "active": True,
        }

        # Include isLatest only if DT version supports it (>= 4.12.0) and current level is > 0
        if self.dt_version >= Version("4.12.0") and remaining_latest_depth > 0:
            data["isLatest"] = True

        # Set up collection logic if supported
        if is_parent and self.dt_version >= Version("4.13.0"):
            data["collectionLogic"] = CollectionLogic[
@@ -468,6 +522,7 @@ class Scanner:
                # create parent project
                parent_uuid = self.get_or_create_project(
                    self.path_separator.join(project_path_parts[:-1]),
                    remaining_latest_depth=remaining_latest_depth - 1,
                    classifier=classifier,
                    is_parent=True,
                )
@@ -526,6 +581,24 @@ class Scanner:
        )
        print(f"- target project: {AnsiColors.YELLOW}{project_path}{AnsiColors.RESET}")

        # check if latest_depth is coherent with project_path
        project_path_parts = project_path.split(self.path_separator)
        if self.latest_depth > len(project_path_parts):
            fail(
                f"latest-depth ({self.latest_depth}) is greater than project path depth ({len(project_path_parts)})"
            )

        # If latest_depth > 1, we must ensure parents are latest.
        # /v1/bom doesn't support this, so we do it manually.
        # This also ensures the project hierarchy is created if it doesn't exist.
        if self.latest_depth > 1:
            self.get_or_create_project(
                project_path,
                remaining_latest_depth=self.latest_depth,
                classifier=sbom_type or "application",
                is_parent=False,
            )

        # finally trim purls
        if self.purl_max_len > 0:
            print(
@@ -553,8 +626,10 @@ class Scanner:
        project_def = DtProjectDef(project_path_parts[-1])
        params = project_def.params

        if self.has_permission(DtPermission.PROJECT_CREATION_UPLOAD):
            params["autoCreate"] = "true"
        # Use autoCreate only if latest_depth <= 1
        # (because autoCreate doesn't support marking parents as latest)
        if self.has_permission(DtPermission.PROJECT_CREATION_UPLOAD) and self.latest_depth <= 1:
            params["autoCreate"] = True
            if len(project_path_parts) > 1:
                parent_def = DtProjectDef(project_path_parts[-2])
                if parent_def.is_uuid:
@@ -565,6 +640,10 @@ class Scanner:
            if self.tags:
                params["projectTags"] = self.tags
        
        # Include isLatest only if DT version supports it (>= 4.12.0)
        if self.dt_version >= Version("4.12.0"):
            params["isLatest"] = self.latest_depth > 0

        # publish SBOM
        print(
            f"- publish params: {AnsiColors.HGRAY}{json.dumps(params)}{AnsiColors.RESET}..."
@@ -594,7 +673,10 @@ class Scanner:
                # replace last path part with project UUID
                # TODO: retrieve classifier from SBOM
                project_path_parts[-1] = "#" + self.get_or_create_project(
                    project_path, sbom_type, is_parent=False
                    project_path,
                    remaining_latest_depth=self.latest_depth,
                    classifier=sbom_type,
                    is_parent=False,
                )
                # then retry
                print("- retry publish...")
@@ -746,6 +828,16 @@ class Scanner:
        ):
            fail("VULNERABILITY_ANALYSIS permission is mandatory to import VEX files")

        if self.latest_depth > 1:
            if not self.has_permission(DtPermission.PORTFOLIO_MANAGEMENT):
                fail(
                    f"PORTFOLIO_MANAGEMENT permission is mandatory to set isLatest flag on parent projects (latest-depth={self.latest_depth})"
                )
            if not self.has_permission(DtPermission.VIEW_PORTFOLIO):
                fail(
                    f"VIEW_PORTFOLIO permission is mandatory to set isLatest flag on parent projects (latest-depth={self.latest_depth})"
                )

        # scan for SBOM files
        sboms = []
        for pattern in sbom_patterns:
@@ -872,6 +964,12 @@ def run() -> None:
        default=os.getenv("DEPTRACK_PARENT_COLLECTION_LOGIC_TAG", ""),
        help="Tag for aggregation if --parent-collection-logic is set to TAG",
    )
    project_selection_group.add_argument(
        "--latest-depth",
        type=int,
        default=int(os.getenv("DEPTRACK_LATEST_DEPTH", "1")),
        help="Number of trailing project path elements to mark as LATEST, default is 1 : only the leaf element (DT version >= 4.12.0)"
    )

    sbom_management_group = parser.add_argument_group("SBOM management")
    sbom_management_group.add_argument(
@@ -968,6 +1066,10 @@ def run() -> None:
        fail(
            "You cannot specify a VEX file with --merged-vex-file (or DEPTRACK_MERGED_VEX_FILE env var) if you are NOT merging SBOM files (--merge is not set)"
        )
    if args.latest_depth < 0:
        fail(
            "latest-depth must be a non-negative integer (use --latest-depth CLI option or DEPTRACK_LATEST_DEPTH variable)"
        )

    # print execution parameters
    print("Scanning SBOM files...")
@@ -1006,6 +1108,9 @@ def run() -> None:
    print(
        f"- risk score (--risk-score-threshold): {AnsiColors.CYAN}{args.risk_score_threshold}{AnsiColors.RESET}"
    )
    print(
        f"- latest depth (--latest-depth): {AnsiColors.CYAN}{args.latest_depth}{AnsiColors.RESET}"
    )
    print(
        f"- insecure             (--insecure): {AnsiColors.CYAN}{args.insecure}{AnsiColors.RESET}"
    )