Commit 5350c38d authored by Clement Bois's avatar Clement Bois Committed by Pierre Smeyers
Browse files

feat: show findings

This feature - when enabled - waits for end of scan from Dependency Track and prints a summary of vulnerability findings
parent 0245364e
Loading
Loading
Loading
Loading
+6 −1
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] [-m] [-o MERGE_OUTPUT] [-i] [--purl-max-len PURL_MAX_LEN] [sbom_patterns ...]
usage: sbom-scanner [-h] [-u BASE_API_URL] [-k API_KEY] [-p PROJECT_PATH] [-s PATH_SEPARATOR] [-m] [-o MERGE_OUTPUT] [-S SHOW_FINDINGS] [-i] [-l PURL_MAX_LEN] [sbom_patterns ...]

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

@@ -61,6 +61,8 @@ options:
  -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
  -f SHOW_FINDINGS, --show-findings SHOW_FINDINGS
                        Wait for analysis and display found vulnerabilities
  -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)
@@ -83,12 +85,15 @@ If none is specified, the program will look for SBOM files matching `**/*.cyclon
| `-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`) |
| `-S` / `--show-findings`  | `$DEPTRACK_SHOW_FINDINGS`  | Wait for analysis and display found vulnerabilities                             |
| `-i` / `--insecure`       | `$DEPTRACK_INSECURE`       | Skip SSL verification                                                           |

## API Key permissions

- In order to be able to publish SBOM files to the Dependency Track server, the `BOM_UPLOAD` permission is **mandatory**.
- The extra `PROJECT_CREATION_UPLOAD` permission is required if you want to automatically create the project while uploading the SBOM files if the project does not exist (but the parent project must exist).
- The extra `VIEW_VULNERABILITY` and `VIEW_PORTFOLIO` permissions are required if you want to display found vulnerabilities after SBOM analysis.<br/>
  Granting those permissions without enabling [Portfolio ACLs](https://github.com/DependencyTrack/dependency-track/issues/1127) is not recommended in the general case as give a read access to all projects.
- The extra `VIEW_PORTFOLIO` and `PORTFOLIO_MANAGEMENT` permissions are required if you want to automatically create one or several project ancestors prior to uploading the SBOM files.<br/>
  Granting those permissions is not recommended in the general case as they virtually give administration rights to the API Key owner.

+88 −11
Original line number Diff line number Diff line
@@ -9,6 +9,7 @@ from enum import Enum
from functools import cache
from logging import Logger
from pathlib import Path
from time import sleep
from typing import Optional

import requests
@@ -201,15 +202,17 @@ class Scanner:
        merge: bool = False,
        merge_output: str = None,
        verify_ssl: bool = True,
        show_findings: bool = False,
    ):
        self.base_api_url = base_api_url
        self.api_key = api_key
        self.project_path = project_path
        self.path_separator = path_separator
        self._purl_max_len = purl_max_len
        self.verify_ssl = verify_ssl
        self.merge = merge
        self.merge_output = merge_output
        self.verify_ssl = verify_ssl
        self.show_findings = show_findings

        self.sbom_count = 0

@@ -500,9 +503,69 @@ class Scanner:
                    sbom_type,
                    allow_retry=False,
                )
                # to prevent show_findings one more time (must have been done in the retried do_publish())
                return
            else:
                raise
        
        if self.show_findings:
            event_id = resp.json()["token"]
            self.do_scan(project_def, event_id)

    def do_scan(self, project_def: DtProjectDef, event_id: str):
        print(f"- scan: {AnsiColors.HGRAY}{event_id}{AnsiColors.RESET}...")
        if project_def.is_uuid:
            project_id = project_def.uuid
        else:
            params = {}
            params["name"] = project_def.name
            if project_def.version:
                params["version"] = project_def.version
            resp = requests.get(
                f"{self.base_api_url}/v1/project/lookup",
                headers={"X-API-Key": self.api_key, "accept": MIME_APPLICATION_JSON},
                params=params,
                verify=self.verify_ssl,
            )
            project_id = resp.json().get("uuid")

        self.wait_for_event_processing(event_id)
        # MAYBE: get SBOM with VEX curl -sSf f"{self.base_api_url}/v1/bom/cyclonedx/project/{project_id}?variant=withVulnerabilities"
        resp = requests.get(
            f"{self.base_api_url}/v1/finding/project/{project_id}",
            headers={"X-API-Key": self.api_key, "accept": MIME_APPLICATION_JSON},
            verify=self.verify_ssl,
        )
        resp.raise_for_status()
        findings = sorted(
            resp.json(),
            key=lambda o: o.get("vulnerability", {}).get("cvssV3BaseScore", 0),
            reverse=True,
        )
        for o in findings:
            vuln = o.get("vulnerability", {})
            component = o.get("component", {})
            cwes = (cwe["name"] for cwe in vuln.get("cwes", []))
            print(
                f'[{vuln.get("severity", "?")}] {" ".join(cwes)} - {component.get("group","")}:{component.get("name")}:{component.get("version","")}'
            )
            print(vuln.get("description", "").strip())
            print()
        print(
            f"- scan {AnsiColors.HGREEN}succeeded{AnsiColors.RESET}: {len(findings)} vulnerabilities found{AnsiColors.RESET}"
        )

    def wait_for_event_processing(self, event_id: str):
        for n in range(8):  # ~5 minutes
            sleep(2**n)
            resp = requests.get(
                f"{self.base_api_url}/v1/event/token/{event_id}",
                headers={"X-API-Key": self.api_key, "accept": MIME_APPLICATION_JSON},
                verify=self.verify_ssl,
            )
            if resp.json().get("processing", False):
                break

    def scan(self, sbom_patterns: list[str]):
        print(
            f"🗝 API key has permissions: {AnsiColors.BLUE}{', '.join(self.get_permissions())}{AnsiColors.RESET}"
@@ -513,6 +576,16 @@ class Scanner:
            fail(
                "BOM_UPLOAD permission is mandatory to publish SBOM files to Dependency Track server"
            )
        if self.show_findings:
            if not self.has_permission(DtPermission.VIEW_VULNERABILITY):
                fail(
                    "VIEW_VULNERABILITY permission is mandatory to show finding after SBOM analysis"
                )
            if not self.has_permission(DtPermission.VIEW_PORTFOLIO):
                fail(
                    "VIEW_PORTFOLIO permission is mandatory to show finding after SBOM analysis"
                )

        # scan for SBOM files
        sboms = []
        for pattern in sbom_patterns:
@@ -614,6 +687,13 @@ def run() -> None:
        default=os.getenv("DEPTRACK_MERGE_OUTPUT"),
        help="Output merged SBOM file (only used with merge enabled) - for debugging purpose",
    )
    parser.add_argument(
        "-S",
        "--show-findings",
        action="store_true",
        default=os.getenv("DEPTRACK_SHOW_FINDINGS") in ["true", "yes", "1"],
        help="Wait for analysis and display found vulnerabilities",
    )
    parser.add_argument(
        "-i",
        "--insecure",
@@ -642,15 +722,15 @@ def run() -> None:
    args = parser.parse_args()

    # check required args
    if not args.base_api_url and not os.getenv("DEPTRACK_BASE_API_URL"):
    if not args.base_api_url:
        fail(
            "Dependency Track server base API url is required (use --base-api-url CLI option or DEPTRACK_BASE_API_URL variable)"
        )
    if not args.api_key and not os.getenv("DEPTRACK_API_KEY"):
    if not args.api_key:
        fail(
            "Dependency Track API key is required (use --api-key CLI option or DEPTRACK_API_KEY variable)"
        )
    if not args.project_path and not os.getenv("DEPTRACK_PROJECT_PATH"):
    if not args.project_path:
        fail(
            "Dependency Track target project path is required (use --project-path CLI option or DEPTRACK_PROJECT_PATH variable)"
        )
@@ -675,6 +755,9 @@ def run() -> None:
    print(
        f"- merge output     (--merge-output): {AnsiColors.CYAN}{args.merge_output}{AnsiColors.RESET}"
    )
    print(
        f"- show findings   (--show-findings): {AnsiColors.CYAN}{args.show_findings}{AnsiColors.RESET}"
    )
    print(
        f"- insecure         (--insecure)    : {AnsiColors.CYAN}{args.insecure}{AnsiColors.RESET}"
    )
@@ -685,13 +768,7 @@ def run() -> None:

    # execute the scan
    scanner = Scanner(
        base_api_url=args.base_api_url,
        api_key=args.api_key,
        project_path=args.project_path,
        path_separator=args.path_separator,
        purl_max_len=args.purl_max_len,
        merge=args.merge,
        merge_output=args.merge_output,
        **args,
        verify_ssl=not args.insecure,
    )
    scanner.scan(args.sbom_patterns)