Commit 7365d3ba authored by Pierre Smeyers's avatar Pierre Smeyers
Browse files

Merge branch 'feat/quality-gate' into 'main'

feat: risk score threshold

Closes to-be-continuous/dependency-track#4

See merge request to-be-continuous/tools/dt-sbom-scanner!15
parents 1c635815 0cf502ed
Loading
Loading
Loading
Loading
+17 −15
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] [-S SHOW_FINDINGS] [-i] [-l 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] [-R RISK_SCORE_THRESHOLD] [-i] [-l PURL_MAX_LEN] [sbom_patterns ...]

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

@@ -61,8 +61,9 @@ 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
  -S, --show-findings   Wait for analysis and display found vulnerabilities
  -R RISK_SCORE_THRESHOLD, --risk-score-threshold RISK_SCORE_THRESHOLD
                        Risk score threshold to fail the scan (<0: disabled - default: -1)
  -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)
@@ -77,7 +78,7 @@ 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**)   |
@@ -86,13 +87,14 @@ If none is specified, the program will look for SBOM files matching `**/*.cyclon
| `-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                             |
| `-R` / `--risk-score-threshold` | `$DEPTRACK_RISK_SCORE_THRESHOLD`  | Risk score threshold to fail the scan (`<0`: disabled - default: `-1`)          |
| `-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/>
- The extra `VIEW_VULNERABILITY` and `VIEW_PORTFOLIO` permissions are required if you want to display found vulnerabilities or compute risk score 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.
+63 −15
Original line number Diff line number Diff line
@@ -5,6 +5,7 @@ import os
import re
import ssl
import sys
from dataclasses import dataclass
from enum import Enum
from functools import cache
from logging import Logger
@@ -28,7 +29,26 @@ INSECURE_SSL_CTX.verify_mode = ssl.CERT_NONE

MIME_APPLICATION_JSON = "application/json"

SEVERITY_COLOR = [AnsiColors.HRED, AnsiColors.RED, AnsiColors.YELLOW, AnsiColors.GREEN, AnsiColors.RESET, AnsiColors.PURPLE]

@dataclass
class DtSeverity:
    """Dependency Track severity level"""

    name: str
    risk_score: int
    """See: https://docs.dependencytrack.org/terminology/#risk-score"""
    color: str


SEVERITY_RANKS = [
    DtSeverity("Critical", 10, AnsiColors.HRED),
    DtSeverity("High", 5, AnsiColors.RED),
    DtSeverity("Medium", 3, AnsiColors.YELLOW),
    DtSeverity("Low", 1, AnsiColors.GREEN),
    DtSeverity("Informational", 0, AnsiColors.RESET),
    DtSeverity("Unassigned", 5, AnsiColors.PURPLE)
]


class DtPermission(str, Enum):
    """Dependency Track permissions.
@@ -204,6 +224,7 @@ class Scanner:
        merge_output: str = None,
        verify_ssl: bool = True,
        show_findings: bool = False,
        risk_score_threshold: int = -1,
        **_: None,
    ):
        self.base_api_url = base_api_url
@@ -215,8 +236,10 @@ class Scanner:
        self.merge_output = merge_output
        self.verify_ssl = verify_ssl
        self.show_findings = show_findings
        self.risk_score_threshold = risk_score_threshold

        self.sbom_count = 0
        self.sbom_scan_failed = 0

    @property
    @cache
@@ -256,6 +279,11 @@ class Scanner:
        """Determines the DT bom/token or event/token path depending on the DT server version."""
        return "bom/token" if self.dt_version < Version("4.11.0") else "event/token"

    @property
    @cache
    def need_findings(self) -> bool:
        return self.show_findings or self.risk_score_threshold >= 0

    @cache
    def get_permissions(self) -> list[DtPermission]:
        return [
@@ -511,12 +539,12 @@ class Scanner:
                    sbom_type,
                    allow_retry=False,
                )
                # to prevent show_findings one more time (must have been done in the retried do_publish())
                # to prevent do_scan one more time (must have been done in the retried do_publish())
                return
            else:
                raise

        if self.show_findings:
        if self.need_findings:
            event_id = resp.json()["token"]
            self.do_scan(project_def, event_id)

@@ -545,6 +573,7 @@ class Scanner:
            verify=self.verify_ssl,
        )
        resp.raise_for_status()
        risk_score = 0
        findings = sorted(
            resp.json(),
            key=lambda o: o.get("vulnerability", {}).get("cvssV3BaseScore", 0),
@@ -553,15 +582,23 @@ class Scanner:
        for o in findings:
            vuln = o.get("vulnerability", {})
            component = o.get("component", {})
            color = SEVERITY_COLOR[vuln.get("severityRank", 5)]
            severity = SEVERITY_RANKS[vuln.get("severityRank", 5)]
            cwes = (cwe["name"] for cwe in vuln.get("cwes", []))
            risk_score += severity.risk_score
            if self.show_findings:
                print(
                f'  - {vuln["vulnId"]} {color}{vuln.get("severity", "?").capitalize()}{AnsiColors.RESET}: {component.get("group","")}:{component.get("name")}:{component.get("version","")} - {" ".join(cwes)}'
                    f'  - {vuln["vulnId"]} {severity.color}{severity.name}{AnsiColors.RESET}: {component.get("group","")}:{component.get("name")}:{component.get("version","")} - {" ".join(cwes)}'
                )
                print(re.sub('\n+', '\n', vuln.get("description", "").strip()))
                print()
        if self.risk_score_threshold < 0 or risk_score < self.risk_score_threshold:
            print(
            f"- scan {AnsiColors.HGREEN}succeeded{AnsiColors.RESET}: {len(findings)} vulnerabilities found{AnsiColors.RESET}"
                f"- scan {AnsiColors.HGREEN}succeeded{AnsiColors.RESET}: {len(findings)} vulnerabilities found {AnsiColors.HGRAY}risk score: {risk_score}{AnsiColors.RESET}"
            )
        else:
            self.sbom_scan_failed += 1
            print(
                f"- scan {AnsiColors.HRED}failed{AnsiColors.RESET}: risk score {risk_score} exceeds threshold {self.risk_score_threshold} - failing the scan: {AnsiColors.HGRAY}{len(findings)} vulnerabilities found{AnsiColors.RESET}"
            )

    def wait_for_event_processing(self, event_id: str):
@@ -579,20 +616,19 @@ class Scanner:
        print(
            f"🗝 API key has permissions: {AnsiColors.BLUE}{', '.join(self.get_permissions())}{AnsiColors.RESET}"
        )
        self.purl_max_len
        print()
        if not self.has_permission(DtPermission.BOM_UPLOAD):
            fail(
                "BOM_UPLOAD permission is mandatory to publish SBOM files to Dependency Track server"
            )
        if self.show_findings:
        if self.need_findings:
            if not self.has_permission(DtPermission.VIEW_VULNERABILITY):
                fail(
                    "VIEW_VULNERABILITY permission is mandatory to show finding after SBOM analysis"
                    "VIEW_VULNERABILITY permission is mandatory to show finding or compute risk score after SBOM analysis"
                )
            if not self.has_permission(DtPermission.VIEW_PORTFOLIO):
                fail(
                    "VIEW_PORTFOLIO permission is mandatory to show finding after SBOM analysis"
                    "VIEW_PORTFOLIO permission is mandatory to show finding or compute risk score after SBOM analysis"
                )

        # scan for SBOM files
@@ -703,6 +739,13 @@ def run() -> None:
        default=os.getenv("DEPTRACK_SHOW_FINDINGS") in ["true", "yes", "1"],
        help="Wait for analysis and display found vulnerabilities",
    )
    parser.add_argument(
        "-R",
        "--risk-score-threshold",
        type=int,
        default=int(os.getenv("DEPTRACK_RISK_SCORE_THRESHOLD", "-1")),
        help="Risk score threshold to fail the scan (<0: disabled - default: -1)",
    )
    parser.add_argument(
        "-i",
        "--insecure",
@@ -767,6 +810,9 @@ def run() -> None:
    print(
        f"- show findings    (--show-findings): {AnsiColors.CYAN}{args.show_findings}{AnsiColors.RESET}"
    )
    print(
        f"- risk score       (--risk-score-threshold): {AnsiColors.CYAN}{args.risk_score_threshold}{AnsiColors.RESET}"
    )
    print(
        f"- insecure         (--insecure)    : {AnsiColors.CYAN}{args.insecure}{AnsiColors.RESET}"
    )
@@ -787,3 +833,5 @@ def run() -> None:
        "----------------------------------------------------------------------------------------------"
    )
    print(f"Summary: {scanner.sbom_count} SBOM published")
    if scanner.sbom_count and scanner.sbom_scan_failed:
        fail(f"{scanner.sbom_scan_failed} SBOM scan failed. Check the logs for details.")