summaryrefslogtreecommitdiff
path: root/buildscripts/blackduck_hub.py
diff options
context:
space:
mode:
Diffstat (limited to 'buildscripts/blackduck_hub.py')
-rw-r--r--buildscripts/blackduck_hub.py1334
1 files changed, 1334 insertions, 0 deletions
diff --git a/buildscripts/blackduck_hub.py b/buildscripts/blackduck_hub.py
new file mode 100644
index 00000000000..111f0d7b03c
--- /dev/null
+++ b/buildscripts/blackduck_hub.py
@@ -0,0 +1,1334 @@
+#!/usr/bin/env python3
+"""Utility script to run Black Duck scans and query Black Duck database."""
+#pylint: disable=too-many-lines
+
+import argparse
+import functools
+import io
+import json
+import logging
+import os
+import re
+import subprocess
+import sys
+import tempfile
+import time
+import warnings
+
+from abc import ABCMeta, abstractmethod
+from typing import Dict, List, Optional
+
+import urllib3.util.retry as urllib3_retry
+import requests
+import yaml
+
+from blackduck.HubRestApi import HubInstance
+
+try:
+ import requests.packages.urllib3.exceptions as urllib3_exceptions #pylint: disable=ungrouped-imports
+except ImportError:
+ # Versions of the requests package prior to 1.2.0 did not vendor the urllib3 package.
+ urllib3_exceptions = None
+
+LOGGER = logging.getLogger(__name__)
+
+############################################################################
+
+# Name of project to upload to and query about
+BLACKDUCK_PROJECT = "mongodb/mongo"
+
+# Version of project to query about
+# Black Duck automatically determines the version based on branch
+BLACKDUCK_PROJECT_VERSION = "master"
+
+# Timeout to wait for a Black Duck scan to complete
+BLACKDUCK_TIMEOUT_SECS = 600
+
+# Black Duck hub api uses this file to get settings
+BLACKDUCK_RESTCONFIG = ".restconfig.json"
+
+# Wiki page where we document more information about Black Duck
+BLACKDUCK_WIKI_PAGE = "https://wiki.corp.mongodb.com/display/KERNEL/Black+Duck"
+
+# Black Duck failed report prefix
+BLACKDUCK_FAILED_PREFIX = "A Black Duck scan was run and failed"
+
+# Black Duck default teaam
+BLACKDUCK_DEFAULT_TEAM = "Service Development Platform"
+
+############################################################################
+
+# Globals
+BLACKDUCK_PROJECT_URL = None
+
+############################################################################
+
+# Build Logger constants
+
+BUILD_LOGGER_CREATE_BUILD_ENDPOINT = "/build"
+BUILD_LOGGER_APPEND_GLOBAL_LOGS_ENDPOINT = "/build/%(build_id)s"
+BUILD_LOGGER_CREATE_TEST_ENDPOINT = "/build/%(build_id)s/test"
+BUILD_LOGGER_APPEND_TEST_LOGS_ENDPOINT = "/build/%(build_id)s/test/%(test_id)s"
+
+BUILD_LOGGER_DEFAULT_URL = "https://logkeeper.mongodb.org"
+BUILD_LOGGER_TIMEOUT_SECS = 65
+
+LOCAL_REPORTS_DIR = "bd_reports"
+
+############################################################################
+
+THIRD_PARTY_DIRECTORIES = [
+ 'src/third_party/wiredtiger/test/3rdparty',
+ 'src/third_party',
+]
+
+THIRD_PARTY_COMPONENTS_FILE = "etc/third_party_components.yml"
+
+############################################################################
+
+RE_LETTERS = re.compile("[A-Za-z]{2,}")
+
+
+def default_if_none(value, default):
+ """Set default if value is 'None'."""
+ return value if value is not None else default
+
+
+# Derived from buildscripts/resmokelib/logging/handlers.py
+class HTTPHandler(object):
+ """A class which sends data to a web server using POST requests."""
+
+ def __init__(self, url_root, username, password, should_retry=False):
+ """Initialize the handler with the necessary authentication credentials."""
+
+ self.auth_handler = requests.auth.HTTPBasicAuth(username, password)
+
+ self.session = requests.Session()
+
+ if should_retry:
+ retry_status = [500, 502, 503, 504] # Retry for these statuses.
+ retry = urllib3_retry.Retry(
+ backoff_factor=0.1, # Enable backoff starting at 0.1s.
+ allowed_methods=False, # Support all HTTP verbs.
+ status_forcelist=retry_status)
+
+ adapter = requests.adapters.HTTPAdapter(max_retries=retry)
+ self.session.mount('http://', adapter)
+ self.session.mount('https://', adapter)
+
+ self.url_root = url_root
+
+ def make_url(self, endpoint):
+ """Generate a url to post to."""
+ return "%s/%s/" % (self.url_root.rstrip("/"), endpoint.strip("/"))
+
+ def post(self, endpoint, data=None, headers=None, timeout_secs=BUILD_LOGGER_TIMEOUT_SECS):
+ """
+ Send a POST request to the specified endpoint with the supplied data.
+
+ Return the response, either as a string or a JSON object based
+ on the content type.
+ """
+
+ data = default_if_none(data, [])
+ data = json.dumps(data)
+
+ headers = default_if_none(headers, {})
+ headers["Content-Type"] = "application/json; charset=utf-8"
+
+ url = self.make_url(endpoint)
+
+ LOGGER.info("POSTING to %s", url)
+
+ with warnings.catch_warnings():
+ if urllib3_exceptions is not None:
+ try:
+ warnings.simplefilter("ignore", urllib3_exceptions.InsecurePlatformWarning)
+ except AttributeError:
+ # Versions of urllib3 prior to 1.10.3 didn't define InsecurePlatformWarning.
+ # Versions of requests prior to 2.6.0 didn't have a vendored copy of urllib3
+ # that defined InsecurePlatformWarning.
+ pass
+
+ try:
+ warnings.simplefilter("ignore", urllib3_exceptions.InsecureRequestWarning)
+ except AttributeError:
+ # Versions of urllib3 prior to 1.9 didn't define InsecureRequestWarning.
+ # Versions of requests prior to 2.4.0 didn't have a vendored copy of urllib3
+ # that defined InsecureRequestWarning.
+ pass
+
+ response = self.session.post(url, data=data, headers=headers, timeout=timeout_secs,
+ auth=self.auth_handler, verify=True)
+
+ response.raise_for_status()
+
+ if not response.encoding:
+ response.encoding = "utf-8"
+
+ headers = response.headers
+
+ if headers["Content-Type"].startswith("application/json"):
+ return response.json()
+
+ return response.text
+
+
+# Derived from buildscripts/resmokelib/logging/buildlogger.py
+class BuildloggerServer(object):
+ # pylint: disable=too-many-instance-attributes
+ """
+ A remote server to which build logs can be sent.
+
+ It is used to retrieve handlers that can then be added to logger
+ instances to send the log to the servers.
+ """
+
+ def __init__(self, username, password, task_id, builder, build_num, build_phase, url):
+ # pylint: disable=too-many-arguments
+ """Initialize BuildloggerServer."""
+ self.username = username
+ self.password = password
+ self.builder = builder
+ self.build_num = build_num
+ self.build_phase = build_phase
+ self.url = url
+ self.task_id = task_id
+
+ self.handler = HTTPHandler(url_root=self.url, username=self.username,
+ password=self.password, should_retry=True)
+
+ def new_build_id(self, suffix):
+ """Return a new build id for sending global logs to."""
+ builder = "%s_%s" % (self.builder, suffix)
+ build_num = int(self.build_num)
+
+ response = self.handler.post(
+ BUILD_LOGGER_CREATE_BUILD_ENDPOINT, data={
+ "builder": builder,
+ "buildnum": build_num,
+ "task_id": self.task_id,
+ })
+
+ return response["id"]
+
+ def new_test_id(self, build_id, test_filename, test_command):
+ """Return a new test id for sending test logs to."""
+ endpoint = BUILD_LOGGER_CREATE_TEST_ENDPOINT % {"build_id": build_id}
+
+ response = self.handler.post(
+ endpoint, data={
+ "test_filename": test_filename,
+ "command": test_command,
+ "phase": self.build_phase,
+ "task_id": self.task_id,
+ })
+
+ return response["id"]
+
+ def post_new_file(self, build_id, test_name, lines):
+ """Post a new file to the build logger server."""
+ test_id = self.new_test_id(build_id, test_name, "foo")
+ endpoint = BUILD_LOGGER_APPEND_TEST_LOGS_ENDPOINT % {
+ "build_id": build_id,
+ "test_id": test_id,
+ }
+
+ dt = time.time()
+
+ dlines = [(dt, line) for line in lines]
+
+ try:
+ self.handler.post(endpoint, data=dlines)
+ except requests.HTTPError as err:
+ # Handle the "Request Entity Too Large" error, set the max size and retry.
+ raise ValueError("Encountered an HTTP error: %s" % (err))
+ except requests.RequestException as err:
+ raise ValueError("Encountered a network error: %s" % (err))
+ except: # pylint: disable=bare-except
+ raise ValueError("Encountered an error.")
+
+ return self.handler.make_url(endpoint)
+
+
+def _to_dict(items, func):
+ dm = {}
+
+ for i in items:
+ tuple1 = func(i)
+ dm[tuple1[0]] = tuple1[1]
+
+ return dm
+
+
+def _compute_security_risk(security_risk_profile):
+ counts = security_risk_profile["counts"]
+
+ cm = _to_dict(counts, lambda i: (i["countType"], int(i["count"])))
+
+ priorities = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'OK', 'UNKNOWN']
+
+ for priority in priorities:
+ if cm[priority] > 0:
+ return priority
+
+ return "OK"
+
+
+@functools.total_ordering
+class VersionInfo:
+ """Parse and break apart version strings so they can be compared."""
+
+ def __init__(self, ver_str):
+ """Parse a version string input a tuple of ints or mark it as a beta release."""
+ try:
+
+ self.ver_str = ver_str
+ self.production_version = True
+
+ # Abseil has an empty string for one version
+ # Abseil thinks "~" is character to use in versions
+ # Geary thinks "+" is character to use in versions
+ if self.ver_str == "" or "~" in self.ver_str or "+" in self.ver_str:
+ self.production_version = False
+ return
+
+ # Special case Intel's Decimal library since it is just too weird
+ if ver_str == "v2.0 U1":
+ self.ver_array = [2, 0]
+ return
+
+ # BlackDuck thinks boost 1.70.0 was released on 2007 which means we have to check hundreds of versions
+ bad_keywords = ["(", "+b", "-b", "b1", ".0a"]
+ if [bad for bad in bad_keywords if bad in self.ver_str]:
+ self.production_version = False
+ return
+
+ # Clean the version information
+ # Some versions start with 'v'. Some components have a mix of 'v' and not 'v' prefixed versions so trim the 'v'
+ # MongoDB versions start with 'r'
+ if ver_str[0] == 'v' or ver_str[0] == 'r':
+ self.ver_str = ver_str[1:]
+
+ # Git hashes are not valid versions
+ if len(self.ver_str) == 40 and bytes.fromhex(self.ver_str):
+ self.production_version = False
+ return
+
+ # Clean out Mozilla's suffix
+ self.ver_str = self.ver_str.replace("esr", "")
+
+ # Clean out GPerfTool's prefix
+ self.ver_str = self.ver_str.replace("gperftools-", "")
+
+ # Clean out Yaml Cpp's prefix
+ self.ver_str = self.ver_str.replace("yaml-cpp-", "")
+
+ # Clean out Boosts's prefix
+ self.ver_str = self.ver_str.replace("boost-", "")
+ self.ver_str = self.ver_str.replace("asio-", "")
+
+ if self.ver_str.endswith('-'):
+ self.ver_str = self.ver_str[0:-1]
+
+ # Boost keeps varying the version strings so filter for anything with 2 or more ascii charaters
+ if RE_LETTERS.search(self.ver_str):
+ self.production_version = False
+ return
+
+ # Some versions end with "-\d", change the "-" since it just means a patch release from a debian/rpm package
+ # yaml-cpp has this problem where Black Duck sourced the wrong version information
+ self.ver_str = self.ver_str.replace("-", ".")
+
+ # If we trimmed the string to nothing, treat it as a beta version
+ if self.ver_str == '':
+ self.production_version = False
+ return
+
+ # Versions are generally a multi-part integer tuple
+ self.ver_array = [int(part) for part in self.ver_str.split(".")]
+
+ except:
+ LOGGER.error("Failed to parse version '%s' as '%s', exception", ver_str, self.ver_str)
+ raise
+
+ def __repr__(self):
+ return self.__str__()
+
+ def __str__(self):
+ return ".".join([str(val) for val in self.ver_array])
+
+ def __eq__(self, other):
+ return (self.production_version, self.ver_array) == (other.production_version,
+ other.ver_array)
+
+ def __gt__(self, other):
+ if self.production_version != other.production_version:
+ return self.production_version
+
+ return self.ver_array > other.ver_array
+
+
+def _test_version_info():
+ VersionInfo("v2.0 U1")
+ VersionInfo("60.7.0-esr")
+ VersionInfo("v1.1")
+ VersionInfo("0.4.2-1")
+ VersionInfo("7.0.2")
+ VersionInfo("gperftools-2.8")
+ VersionInfo("v1.5-rc2")
+ VersionInfo("r4.7.0-alpha")
+ VersionInfo("r4.2.10")
+ VersionInfo("2.0.0.1")
+ VersionInfo("7.0.2-2")
+ VersionInfo("git")
+ VersionInfo("20200225.2")
+ VersionInfo('release-68-alpha')
+ VersionInfo('cldr/2020-09-22')
+ VersionInfo('release-67-rc')
+ VersionInfo('66.1~rc')
+ VersionInfo('release-66-rc')
+ VersionInfo('release-66-preview')
+ VersionInfo('65.1')
+ VersionInfo('release-65-rc')
+ VersionInfo('64.2-rc')
+ VersionInfo('release-64-rc2')
+ VersionInfo('release-63-rc')
+ VersionInfo('last-cvs-commit')
+ VersionInfo('last-svn-commit')
+ VersionInfo('release-62-rc')
+ VersionInfo('cldr-32-beta2')
+ VersionInfo('release-60-rc')
+ VersionInfo('milestone-60-0-1')
+ VersionInfo('release-59-rc')
+ VersionInfo('milestone-59-0-1')
+ VersionInfo('release-58-2-eclipse-20170118')
+ VersionInfo('tools-release-58')
+ VersionInfo('icu-latest')
+ VersionInfo('icu4j-latest')
+ VersionInfo('icu4j-release-58-1')
+ VersionInfo('icu4j-release-58-rc')
+ VersionInfo('icu-release-58-rc')
+ VersionInfo('icu-milestone-58-0-1')
+ VersionInfo('icu4j-milestone-58-0-1')
+
+ VersionInfo('yaml-cpp-0.6.3')
+
+ VersionInfo('gb-c8-task188949.100')
+ VersionInfo('1.2.8-alt1.M80C.1')
+ VersionInfo('1.2.8-alt2')
+
+ assert VersionInfo('7.0.2.2') > VersionInfo('7.0.0.1')
+ assert VersionInfo('7.0.2.2') > VersionInfo('7.0.2')
+ assert VersionInfo('7.0.2.2') > VersionInfo('3.1')
+ assert VersionInfo('7.0.2.2') <= VersionInfo('8.0.2')
+
+
+def _retry_on_except(count, func):
+ # Retry func() COUNT times until func() does not raise an exception
+ # pylint: disable=bare-except
+ retry = 0
+ while retry < count:
+
+ try:
+ return func()
+ except:
+ exception_info = sys.exc_info()[0]
+ LOGGER.error("Failed to execute retriable function (%s), retrying", exception_info)
+
+ retry += 1
+
+ raise ValueError("Failed to run query after retries %s" % (count))
+
+
+class Component:
+ """
+ Black Duck Component description.
+
+ Contains a subset of information about a component extracted from Black Duck for a given project and version
+ """
+
+ def __init__(self, name, version, licenses, policy_status, security_risk, newest_release,
+ is_manually_added):
+ # pylint: disable=too-many-arguments
+ """Initialize Black Duck component."""
+ self.name = name
+ self.version = version
+ self.licenses = licenses
+ self.policy_status = policy_status
+ self.security_risk = security_risk
+ self.newest_release = newest_release
+ self.is_manually_added = is_manually_added
+
+ @staticmethod
+ def parse(hub, component):
+ # pylint: disable=too-many-locals
+ """Parse a Black Duck component from a dictionary."""
+ name = component["componentName"]
+ cversion = component.get("componentVersionName", "unknown_version")
+ licenses = ",".join([a.get("spdxId", a["licenseDisplay"]) for a in component["licenses"]])
+
+ policy_status = component["policyStatus"]
+ security_risk = _compute_security_risk(component['securityRiskProfile'])
+
+ newer_releases = component["activityData"].get("newerReleases", 0)
+
+ is_manually_added = 'MANUAL_BOM_COMPONENT' in component['matchTypes']
+
+ LOGGER.info("Retrievinng version information for Comp %s - %s Releases %s", name, cversion,
+ newer_releases)
+ cver = VersionInfo(cversion)
+ newest_release = None
+
+ # Blackduck's newerReleases is based on "releasedOn" date. This means that if a upstream component releases a beta or rc,
+ # it counts as newer but we do not consider those newer for our purposes
+ # Missing newerReleases means we do not have to upgrade
+ if newer_releases > 0:
+ limit = newer_releases + 1
+ versions_url = component["component"] + f"/versions?sort=releasedon%20desc&limit={limit}"
+
+ LOGGER.info("Retrieving version information via %s", versions_url)
+
+ def get_version_info():
+ vjson = hub.execute_get(versions_url).json()
+
+ if "items" not in vjson:
+ LOGGER.warn("Missing items in response: %s", vjson)
+ raise ValueError("Missing items in response for " + versions_url)
+
+ return vjson
+
+ vjson = _retry_on_except(5, get_version_info)
+
+ versions = [(ver["versionName"], ver["releasedOn"]) for ver in vjson["items"]]
+
+ LOGGER.info("Known versions: %s ", versions)
+
+ versions = [ver["versionName"] for ver in vjson["items"]]
+
+ # For Firefox, only examine Extended Service Releases (i.e. esr), their long term support releases
+ if name == "Mozilla Firefox":
+ versions = [ver for ver in versions if "esr" in ver]
+
+ # For yaml-cpp, we need to clean the list of versions a little
+ # yaml-cpp uses #.#.# but there are some entires with #.#.#.# so the later needs to
+ # be filtered out.
+ if name == "jbeder/yaml-cpp":
+ ver_regex = re.compile(r"\d+\.\d+\.\d+$")
+ versions = [ver for ver in versions if ver_regex.match(ver)]
+
+ # For Boost C++ Libraries - boost, we need to clean the list of versions a little
+ # All boost versions for the last 10 years start with 1.x.x. Black Duck thinks some
+ # versions are 4.x.x which are bogus and throw off the sorting.
+ # Also, boost uses #.#.# but there are some entires with #.#.#.# so the later needs to
+ # be filtered out.
+ if name == "Boost C++ Libraries - boost":
+ ver_regex = re.compile(r"\d+\.\d+\.\d+$")
+ versions = [ver for ver in versions if ver.startswith("1") and ver_regex.match(ver)]
+
+ ver_info = [VersionInfo(ver) for ver in versions]
+ ver_info = [ver for ver in ver_info if ver.production_version]
+ LOGGER.info("Filtered versions: %s ", ver_info)
+
+ ver_info = sorted([ver for ver in ver_info if ver.production_version and ver > cver],
+ reverse=True)
+
+ LOGGER.info("Sorted versions: %s ", ver_info)
+
+ if ver_info:
+ newest_release = ver_info[0]
+
+ return Component(name, cversion, licenses, policy_status, security_risk, newest_release,
+ is_manually_added)
+
+
+class BlackDuckConfig:
+ """
+ Black Duck configuration settings.
+
+ Format is defined by Black Duck Python hub API.
+ """
+
+ def __init__(self):
+ """Init Black Duck config from disk."""
+ if not os.path.exists(BLACKDUCK_RESTCONFIG):
+ raise ValueError("Cannot find %s for blackduck configuration" % (BLACKDUCK_RESTCONFIG))
+
+ with open(BLACKDUCK_RESTCONFIG, "r") as rfh:
+ rc = json.loads(rfh.read())
+
+ self.url = rc["baseurl"]
+ self.username = rc["username"]
+ self.password = rc["password"]
+
+
+def _run_scan():
+ # Get user name and password from .restconfig.json
+ bdc = BlackDuckConfig()
+
+ with tempfile.NamedTemporaryFile() as fp:
+ fp.write(f"""#/!bin/sh
+curl --retry 5 -s -L https://detect.synopsys.com/detect.sh | bash -s -- --blackduck.url={bdc.url} --blackduck.username={bdc.username} --blackduck.password={bdc.password} --detect.report.timeout={BLACKDUCK_TIMEOUT_SECS} --snippet-matching --upload-source --detect.wait.for.results=true
+""".encode())
+ fp.flush()
+
+ subprocess.check_call(["/bin/sh", fp.name])
+
+
+def _scan_cmd_args(args):
+ # pylint: disable=unused-argument
+ LOGGER.info("Running Black Duck Scan")
+
+ _run_scan()
+
+
+def _query_blackduck():
+ # pylint: disable=global-statement
+ global BLACKDUCK_PROJECT_URL
+
+ hub = HubInstance()
+
+ LOGGER.info("Getting version from blackduck")
+ version = hub.execute_get(hub.get_urlbase() + "/api/current-version").json()
+ LOGGER.info("Version: %s", version)
+
+ # Get a list of all projects, this is a privileged call and will fail if we do not have a valid license
+ LOGGER.info("Get All Projects")
+ projects = hub.get_projects()
+ LOGGER.info("Projects: %s", projects)
+
+ LOGGER.info("Fetching project %s from blackduck", BLACKDUCK_PROJECT)
+ project = hub.get_project_by_name(BLACKDUCK_PROJECT)
+
+ LOGGER.info("Fetching project version %s from blackduck", BLACKDUCK_PROJECT_VERSION)
+ version = hub.get_version_by_name(project, BLACKDUCK_PROJECT_VERSION)
+
+ LOGGER.info("Getting version components from blackduck")
+ bom_components = hub.get_version_components(version)
+
+ components = [
+ Component.parse(hub, comp) for comp in bom_components["items"] if comp['ignored'] is False
+ ]
+
+ BLACKDUCK_PROJECT_URL = version["_meta"]["href"]
+
+ return components
+
+
+class TestResultEncoder(json.JSONEncoder):
+ """JSONEncoder for TestResults."""
+
+ def default(self, o):
+ """Serialize objects by default as a dictionary."""
+ # pylint: disable=method-hidden
+ return o.__dict__
+
+
+class TestResult:
+ """A single test result in the Evergreen report.json format."""
+
+ def __init__(self, name, status, url):
+ """Init test result."""
+ # This matches the report.json schema
+ # See https://github.com/evergreen-ci/evergreen/blob/789bee107d3ffb9f0f82ae344d72502945bdc914/model/task/task.go#L264-L284
+ assert status in ["pass", "fail"]
+
+ self.test_file = name
+ self.status = status
+ self.exit_code = 1
+
+ if url:
+ self.url = url
+ self.url_raw = url + "?raw=1"
+
+ if status == "pass":
+ self.exit_code = 0
+
+
+class TestResults:
+ """Evergreen TestResult format for report.json."""
+
+ def __init__(self):
+ """Init test results."""
+ self.results = []
+
+ def add_result(self, result: TestResult):
+ """Add a test result."""
+ self.results.append(result)
+
+ def write(self, filename: str):
+ """Write the test results to disk."""
+
+ with open(filename, "w") as wfh:
+ wfh.write(json.dumps(self, cls=TestResultEncoder))
+
+
+class ReportLogger(object, metaclass=ABCMeta):
+ """Base Class for all report loggers."""
+
+ @abstractmethod
+ def log_report(self, name: str, content: str) -> Optional[str]:
+ """Get the command to run a linter."""
+ pass
+
+
+class LocalReportLogger(ReportLogger):
+ """Write reports to local directory as a set of files."""
+
+ def __init__(self):
+ """Init logger and create directory."""
+ if not os.path.exists(LOCAL_REPORTS_DIR):
+ os.mkdir(LOCAL_REPORTS_DIR)
+
+ def log_report(self, name: str, content: str) -> Optional[str]:
+ """Log report to a local file."""
+ file_name = os.path.join(LOCAL_REPORTS_DIR, name + ".log")
+
+ with open(file_name, "w") as wfh:
+ wfh.write(content)
+
+
+class BuildLoggerReportLogger(ReportLogger):
+ """Write reports to a build logger server."""
+
+ def __init__(self, build_logger):
+ """Init logger."""
+ self.build_logger = build_logger
+
+ self.build_id = self.build_logger.new_build_id("bdh")
+
+ def log_report(self, name: str, content: str) -> Optional[str]:
+ """Log report to a build logger."""
+
+ content = content.split("\n")
+
+ return self.build_logger.post_new_file(self.build_id, name, content)
+
+
+def _get_default(list1, idx, default):
+ if (idx + 1) < len(list1):
+ return list1[idx]
+
+ return default
+
+
+class TableWriter:
+ """Generate an ASCII table that summarizes the results of all the reports generated."""
+
+ def __init__(self, headers: List[str]):
+ """Init writer."""
+ self._headers = headers
+ self._rows = []
+
+ def add_row(self, row: List[str]):
+ """Add a row to the table."""
+ self._rows.append(row)
+
+ @staticmethod
+ def _write_row(col_sizes: List[int], row: List[str], writer: io.StringIO):
+ writer.write("|")
+ for idx, row_value in enumerate(row):
+ writer.write(" ")
+ writer.write(row_value)
+ writer.write(" " * (col_sizes[idx] - len(row_value)))
+ writer.write(" |")
+ writer.write("\n")
+
+ def print(self, writer: io.StringIO):
+ """Print the final table to the string stream."""
+ cols = max([len(r) for r in self._rows])
+
+ assert cols == len(self._headers)
+
+ col_sizes = []
+ for col in range(0, cols):
+ col_sizes.append(
+ max([len(_get_default(row, col, []))
+ for row in self._rows] + [len(self._headers[col])]))
+
+ TableWriter._write_row(col_sizes, self._headers, writer)
+
+ TableWriter._write_row(col_sizes, ["-" * c for c in col_sizes], writer)
+
+ for row in self._rows:
+ TableWriter._write_row(col_sizes, row, writer)
+
+
+class TableData:
+ """Store scalar values in a two-dimensional matrix indexed by the first column's value."""
+
+ def __init__(self):
+ """Init table data."""
+ self._rows = {}
+
+ def add_value(self, col: str, value: str):
+ """Add a value for a given column. Order sensitive."""
+ if col not in self._rows:
+ self._rows[col] = []
+
+ self._rows[col].append(value)
+
+ def write(self, headers: List[str], writer: io.StringIO):
+ """Write table data as nice prettty table to writer."""
+ tw = TableWriter(headers)
+
+ for row in self._rows:
+ tw.add_row([row] + self._rows[row])
+
+ tw.print(writer)
+
+
+class ReportManager:
+ """Manage logging reports to ReportLogger and generate summary report."""
+
+ def __init__(self, logger: ReportLogger):
+ """Init report manager."""
+ self._logger = logger
+ self._results = TestResults()
+ self._data = TableData()
+
+ @staticmethod
+ def _get_norm_comp_name(comp_name: str):
+ return comp_name.replace(" ", "_").replace("/", "_").lower()
+
+ def write_report(self, comp_name: str, report_name: str, status: str, content: str):
+ """
+ Write a report about a test to the build logger.
+
+ status is a string of "pass" or "fail"
+ """
+ comp_name = ReportManager._get_norm_comp_name(comp_name)
+
+ name = comp_name + "_" + report_name
+
+ LOGGER.info("Writing Report %s - %s", name, status)
+
+ self._data.add_value(comp_name, status)
+
+ url = self._logger.log_report(name, content)
+
+ self._results.add_result(TestResult(name, status, url))
+
+ def add_report_metric(self, comp_name: str, metric: str):
+ """Add a column to be included in the pretty table."""
+ comp_name = ReportManager._get_norm_comp_name(comp_name)
+
+ self._data.add_value(comp_name, metric)
+
+ def finish(self, reports_file: Optional[str], vulnerabilties_only: bool):
+ """Generate final summary of all reports run."""
+
+ if reports_file:
+ self._results.write(reports_file)
+
+ stream = io.StringIO()
+
+ if vulnerabilties_only:
+ self._data.write(["Component", "Vulnerability"], stream)
+ else:
+ self._data.write(
+ ["Component", "Vulnerability", "Upgrade", "Current Version", "Newest Version"],
+ stream)
+
+ print(stream.getvalue())
+
+
+class ThirdPartyComponent:
+ """MongoDB Third Party component from third_party_components.yml."""
+
+ def __init__(self, name, homepage_url, local_path, team_owner):
+ """Init class."""
+ # Required fields
+ self.name = name
+ self.homepage_url = homepage_url
+ self.local_path = local_path
+ self.team_owner = team_owner
+
+ # optional fields
+ self.is_test_only = False
+ self.vulnerability_suppression = None
+ self.upgrade_suppression = None
+
+
+def _get_field(name, ymap, field: str):
+ if field not in ymap:
+ raise ValueError("Missing field %s for component %s" % (field, name))
+
+ return ymap[field]
+
+
+def _get_supression_field(ymap, field: str):
+ if field not in ymap:
+ return None
+
+ value = ymap[field].lower()
+
+ if not "todo" in value:
+ raise ValueError(
+ "Invalid suppression, a suppression must include the word 'TODO' so that the TODO scanner finds resolved tickets."
+ )
+
+ return value
+
+
+def _read_third_party_components():
+ with open(THIRD_PARTY_COMPONENTS_FILE) as rfh:
+ yaml_file = yaml.load(rfh.read())
+
+ third_party = []
+ components = yaml_file["components"]
+ for comp in components:
+ cmap = components[comp]
+
+ tp = ThirdPartyComponent(comp, _get_field(comp, cmap, 'homepage_url'),
+ _get_field(comp, cmap, 'local_directory_path'),
+ _get_field(comp, cmap, 'team_owner'))
+
+ tp.is_test_only = cmap.get("is_test_only", False)
+ tp.vulnerability_suppression = _get_supression_field(cmap, "vulnerability_suppression")
+ tp.upgrade_suppression = _get_supression_field(cmap, "upgrade_suppression")
+
+ third_party.append(tp)
+
+ return third_party
+
+
+def _generate_report_missing_blackduck_component(mgr: ReportManager, mcomp: ThirdPartyComponent):
+ mgr.write_report(
+ mcomp.name, "missing_blackduck_component", "fail", f"""{BLACKDUCK_FAILED_PREFIX}
+
+The {mcomp.name} library was found in {THIRD_PARTY_COMPONENTS_FILE} but not detected by Black Duck.
+
+This is caused by one of two issues:
+1. The {THIRD_PARTY_COMPONENTS_FILE} file is out of date and the entry needs to be removed for "{mcomp.name}".
+or
+2. A entry to the component needs to be manually added to Black Duck.
+
+Next Steps:
+
+Build Baron:
+A BF ticket should be generated and assigned to "{BLACKDUCK_DEFAULT_TEAM}" with
+this text.
+
+Developer:
+To address this build failure, the next steps are as follows:
+1. Verify that the component is correct in {THIRD_PARTY_COMPONENTS_FILE}.
+
+2. If the component is incorrect, add a comment to the component in Black Duck and mark it as "Ignored".
+or
+2. If the component is correct, add the correct information to {THIRD_PARTY_COMPONENTS_FILE}.
+
+If the "{BLACKDUCK_DEFAULT_TEAM}" cannot do this work for any reason, the BF should be assigned to
+the component owner team "{mcomp.team_owner}".
+
+For more information, see {BLACKDUCK_WIKI_PAGE}.
+""")
+
+
+def _generate_report_blackduck_missing_directory(mgr: ReportManager, directory: str):
+ mgr.write_report(
+ directory, "missing_directory", "fail", f"""{BLACKDUCK_FAILED_PREFIX}
+
+The directory "{directory}" was found in a known MongoDB third_party directory but is not known to
+Black Duck or {THIRD_PARTY_COMPONENTS_FILE}. This directory likely needs to be added to Black Duck
+manually.
+
+Next Steps:
+
+Build Baron:
+A BF ticket should be generated and assigned to "{BLACKDUCK_DEFAULT_TEAM}" with
+this text.
+
+Developer:
+To address this build failure, the next steps are as follows:
+1. Verify that the component is correct.
+2. Add the component manually to the Black Duck project at
+ {BLACKDUCK_PROJECT_URL}.
+3. Once the component has been accepted by Black Duck, add the correct information to
+ {THIRD_PARTY_COMPONENTS_FILE}.
+
+For more information, see {BLACKDUCK_WIKI_PAGE}.
+""")
+
+
+def _generate_report_missing_yaml_component(mgr: ReportManager, comp: Component):
+ mgr.write_report(
+ comp.name, "missing_yaml_component", "fail", f"""{BLACKDUCK_FAILED_PREFIX}
+
+The {comp.name} library with version "{comp.version}" was detected by Black Duck but not found in
+{THIRD_PARTY_COMPONENTS_FILE}.
+
+This is caused by one of two issues:
+1. Black Duck has made an error and the software is not being vendored by MongoDB.
+2. Black Duck is correct and {THIRD_PARTY_COMPONENTS_FILE} must be updated.
+
+Next Steps:
+
+Build Baron:
+A BF ticket should be generated and assigned to "{BLACKDUCK_DEFAULT_TEAM}" with
+this text.
+
+Developer:
+To address this build failure, the next steps are as follows:
+1. Verify that the component is correct at {BLACKDUCK_PROJECT_URL}.
+
+2. If the component is incorrect, add a comment to the component and mark it as "Ignored".
+or
+3. If the component is correct, add the correct information to {THIRD_PARTY_COMPONENTS_FILE}.
+
+For more information, see {BLACKDUCK_WIKI_PAGE}.
+""")
+
+
+def _generate_report_upgrade(mgr: ReportManager, comp: Component, mcomp: ThirdPartyComponent,
+ fail: bool):
+ if not fail:
+ mgr.write_report(comp.name, "upgrade_check", "pass", "Blackduck run passed")
+ else:
+
+ if comp.is_manually_added:
+ component_explanation = f"""This component requires a manual update in the Black Duck Database because it was added to
+Black Duck manually. After the update to the third-party library is committed, please update the
+version information for this component at {BLACKDUCK_PROJECT_URL}. Click on the down arrow on the
+far right of the component, choose edit and specify the new version."""
+ else:
+ component_explanation = """This commponent was automatically detected by Black Duck. Black Duck should automatically detect
+the new version after the library is updated and the daily scanner task runs again."""
+
+ mgr.write_report(
+ comp.name, "upgrade_check", "fail", f"""{BLACKDUCK_FAILED_PREFIX}
+
+The {comp.name} library at {mcomp.local_path} is out of date. The current version is
+"{comp.version}" and the newest version is "{comp.newest_release}" according to Black Duck.
+
+MongoDB policy requires all third-party software to be updated to the latest version on the master
+branch.
+
+Next Steps:
+
+Build Baron:
+A BF ticket should be generated and assigned to "{BLACKDUCK_DEFAULT_TEAM}" with
+this text.
+
+Developer:
+To address this build failure, the next steps are as follows:
+1. File a SERVER ticket to update the software if one already does not exist.
+2. Add a “upgrade_supression” to {THIRD_PARTY_COMPONENTS_FILE} with the SERVER ticket to acknowledge
+ this report. Note that you do not need to immediately update the library, just file a ticket.
+
+{component_explanation}
+
+If the "{BLACKDUCK_DEFAULT_TEAM}" cannot do this work for any reason, the BF should be assigned to
+the component owner team "{mcomp.team_owner}".
+
+For more information, see {BLACKDUCK_WIKI_PAGE}.
+""")
+
+ mgr.add_report_metric(comp.name, str(comp.version))
+ mgr.add_report_metric(comp.name, str(comp.newest_release))
+
+
+def _generate_report_vulnerability(mgr: ReportManager, comp: Component, mcomp: ThirdPartyComponent,
+ fail: bool):
+ if not fail:
+ mgr.write_report(comp.name, "vulnerability_check", "pass", "Blackduck run passed")
+ return
+
+ mgr.write_report(
+ comp.name, "vulnerability_check", "fail", f"""{BLACKDUCK_FAILED_PREFIX}
+
+The {comp.name} library at {mcomp.local_path} had HIGH and/or CRITICAL security issues. The current
+version in Black Duck is "{comp.version}".
+
+MongoDB policy requires all third-party software to be updated to a version clean of HIGH and
+CRITICAL vulnerabilities on the master branch.
+
+Next Steps:
+
+Build Baron:
+A BF ticket should be generated and assigned to "{BLACKDUCK_DEFAULT_TEAM}" with
+this text.
+
+Developer:
+To address this build failure, the next steps are as follows:
+1. File a SERVER ticket to update the software if one already does not exist. Note that you do not
+ need to immediately update the library, just file a ticket.
+2. Add a “vulnerability_supression” to {THIRD_PARTY_COMPONENTS_FILE} with the SERVER ticket to
+ acknowledge this report.
+
+If you believe the library is already up-to-date but Black Duck has the wrong version, please update
+version information for this component at {BLACKDUCK_PROJECT_URL}.
+
+If the "{BLACKDUCK_DEFAULT_TEAM}" cannot do this work for any reason, the BF should be assigned to
+the component owner team "{mcomp.team_owner}".
+
+For more information, see {BLACKDUCK_WIKI_PAGE}.
+""")
+
+
+def _get_third_party_directories():
+ third_party = []
+ for tp in THIRD_PARTY_DIRECTORIES:
+ for entry in os.scandir(tp):
+ if entry.name not in ["scripts"] and entry.is_dir():
+ third_party.append(entry.path)
+
+ return sorted(third_party)
+
+
+class Analyzer:
+ """
+ Analyze the MongoDB source code for software maintence issues.
+
+ Queries Black Duck for out of date software
+ Consults a local yaml file for detailed information about third party components included in the MongoDB source code.
+ """
+
+ def __init__(self):
+ """Init analyzer."""
+ self.third_party_components = None
+ self.third_party_directories = None
+ self.black_duck_components = None
+ self.mgr = None
+
+ def _do_reports(self, vulnerabilties_only: bool):
+ for comp in self.black_duck_components:
+ # 1. Validate if this is in the YAML file
+ if self._verify_yaml_contains_component(comp, vulnerabilties_only):
+
+ # 2. Validate there are no security issues
+ self._verify_vulnerability_status(comp)
+
+ # 3. Check for upgrade issue
+ if not vulnerabilties_only:
+ self._verify_upgrade_status(comp)
+
+ if vulnerabilties_only:
+ return
+
+ # 4. Validate that each third_party directory is in the YAML file
+ self._verify_directories_in_yaml()
+
+ # 5. Verify the YAML file has all the entries in Black Duck
+ self._verify_components_in_yaml()
+
+ def _verify_yaml_contains_component(self, comp: Component, vulnerabilties_only: bool):
+ # It should be rare that Black Duck detects something that is not in the YAML file
+ # As a result, we do not generate a "pass" report for simply be consistent between Black Duck and the yaml file
+ if comp.name not in [c.name for c in self.third_party_components]:
+ if not vulnerabilties_only:
+ _generate_report_missing_yaml_component(self.mgr, comp)
+ return False
+
+ return True
+
+ def _verify_directories_in_yaml(self):
+
+ comp_dirs = [c.local_path for c in self.third_party_components]
+ for cdir in self.third_party_directories:
+ # Ignore WiredTiger since it is not a third-party library but made by MongoDB, Inc.
+ if cdir in ["src/third_party/wiredtiger"]:
+ continue
+
+ if cdir not in comp_dirs:
+ _generate_report_blackduck_missing_directory(self.mgr, cdir)
+
+ def _verify_components_in_yaml(self):
+
+ comp_names = [c.name for c in self.black_duck_components]
+ for mcomp in self.third_party_components:
+ # These components are known to be missing from Black Duck
+ # Aladdin MD5 is a pair of C files for MD5 computation
+ # timelib is simply missing
+ # Unicode is not code
+ if mcomp.name in ["Aladdin MD5", "timelib", "unicode"]:
+ continue
+
+ if mcomp.name not in comp_names:
+ _generate_report_missing_blackduck_component(self.mgr, mcomp)
+
+ def _verify_upgrade_status(self, comp: Component):
+ mcomp = self._get_mongo_component(comp)
+
+ if comp.newest_release and not mcomp.upgrade_suppression and not mcomp.is_test_only:
+ _generate_report_upgrade(self.mgr, comp, mcomp, True)
+ else:
+ _generate_report_upgrade(self.mgr, comp, mcomp, False)
+
+ def _verify_vulnerability_status(self, comp: Component):
+ mcomp = self._get_mongo_component(comp)
+
+ if comp.security_risk in [
+ "HIGH", "CRITICAL"
+ ] and not mcomp.vulnerability_suppression and not mcomp.is_test_only:
+ _generate_report_vulnerability(self.mgr, comp, mcomp, True)
+ else:
+ _generate_report_vulnerability(self.mgr, comp, mcomp, False)
+
+ def _get_mongo_component(self, comp: Component):
+ mcomp = next((x for x in self.third_party_components if x.name == comp.name), None)
+
+ if not mcomp:
+ raise ValueError(
+ "Cannot find third party component for Black Duck Component '%s'. Please update '%s'. "
+ % (comp.name, THIRD_PARTY_COMPONENTS_FILE))
+
+ return mcomp
+
+ def run(self, logger: ReportLogger, report_file: Optional[str], vulnerabilties_only: bool):
+ """Run analysis of Black Duck scan and local files."""
+
+ self.third_party_directories = _get_third_party_directories()
+
+ LOGGER.info("Found the following third party directories: %s", self.third_party_directories)
+
+ self.third_party_components = _read_third_party_components()
+
+ self.black_duck_components = _query_blackduck()
+
+ # Black Duck detects ourself everytime we release a new version
+ # Rather then constantly have to supress this in Black Duck itself which will generate false positives
+ # We filter ourself our of the list of components.
+ self.black_duck_components = [
+ comp for comp in self.black_duck_components
+ if not (comp.name == "MongoDB" or comp.name == "WiredTiger")
+ ]
+
+ # Remove duplicate Black Duck components. We only care about the component with highest version number
+ # Black Duck can detect different versions of the same component for instance if an upgrade of a component happens
+ bd_names = {comp.name for comp in self.black_duck_components}
+ if len(bd_names) != len(self.black_duck_components):
+ LOGGER.warning("Found duplicate Black Duck components")
+ bd_unique = {}
+ for comp in self.black_duck_components:
+ if comp.name in bd_unique:
+ LOGGER.warning("Found duplicate Black Duck component: %s", comp.name)
+ first = bd_unique[comp.name]
+ if comp.version > first.version:
+ bd_unique[comp.name] = comp
+ else:
+ bd_unique[comp.name] = comp
+
+ self.black_duck_components = list(bd_unique.values())
+
+ self.mgr = ReportManager(logger)
+
+ self._do_reports(vulnerabilties_only)
+
+ self.mgr.finish(report_file, vulnerabilties_only)
+
+
+# Derived from buildscripts/resmokelib/logging/buildlogger.py
+def _get_build_logger_from_file(filename, build_logger_url, task_id):
+ tmp_globals = {}
+ config = {}
+
+ # The build logger config file is actually python
+ # It is a mix of quoted strings and ints
+ exec(compile(open(filename, "rb").read(), filename, 'exec'), tmp_globals, config)
+
+ # Rename "slavename" to "username" if present.
+ if "slavename" in config and "username" not in config:
+ config["username"] = config["slavename"]
+ del config["slavename"]
+
+ # Rename "passwd" to "password" if present.
+ if "passwd" in config and "password" not in config:
+ config["password"] = config["passwd"]
+ del config["passwd"]
+
+ return BuildloggerServer(config["username"], config["password"], task_id, config["builder"],
+ config["build_num"], config["build_phase"], build_logger_url)
+
+
+def _generate_reports_args(args):
+ LOGGER.info("Generating Reports")
+
+ # Log to LOCAL_REPORTS_DIR directory unless build logger is explicitly chosen
+ logger = LocalReportLogger()
+
+ if args.build_logger_local:
+ build_logger = BuildloggerServer("fake_user", "fake_pass", "fake_task", "fake_builder", 1,
+ "fake_build_phase", "http://localhost:8080")
+ logger = BuildLoggerReportLogger(build_logger)
+ elif args.build_logger:
+ if not args.build_logger_task_id:
+ raise ValueError("Must set build_logger_task_id if using build logger")
+
+ build_logger = _get_build_logger_from_file(args.build_logger, args.build_logger_url,
+ args.build_logger_task_id)
+ logger = BuildLoggerReportLogger(build_logger)
+
+ analyzer = Analyzer()
+ analyzer.run(logger, args.report_file, args.vulnerabilities_only)
+
+
+def _scan_and_report_args(args):
+ LOGGER.info("Running Black Duck Scan And Generating Reports")
+
+ _run_scan()
+
+ _generate_reports_args(args)
+
+
+def main() -> None:
+ """Execute Main entry point."""
+
+ parser = argparse.ArgumentParser(description='Black Duck hub controller.')
+
+ parser.add_argument('-v', "--verbose", action='store_true', help="Enable verbose logging")
+ parser.add_argument('-d', "--debug", action='store_true', help="Enable debug logging")
+
+ sub = parser.add_subparsers(title="Hub subcommands", help="sub-command help")
+ generate_reports_cmd = sub.add_parser('generate_reports',
+ help='Generate reports from Black Duck')
+
+ generate_reports_cmd.add_argument("--report_file", type=str,
+ help="report json file to write to")
+ generate_reports_cmd.add_argument(
+ "--build_logger", type=str, help="Log to build logger with credentials from specified file")
+ generate_reports_cmd.add_argument("--build_logger_url", type=str,
+ default=BUILD_LOGGER_DEFAULT_URL,
+ help="build logger url to log to")
+ generate_reports_cmd.add_argument("--build_logger_task_id", type=str,
+ help="build logger task id")
+ generate_reports_cmd.add_argument("--build_logger_local", action='store_true',
+ help="Log to local build logger, logs to disk by default")
+ generate_reports_cmd.add_argument("--vulnerabilities_only", action='store_true',
+ help="Only check for security vulnerabilities")
+ generate_reports_cmd.set_defaults(func=_generate_reports_args)
+
+ scan_cmd = sub.add_parser('scan', help='Do Black Duck Scan')
+ scan_cmd.set_defaults(func=_scan_cmd_args)
+
+ scan_and_report_cmd = sub.add_parser('scan_and_report',
+ help='Run scan and then generate reports')
+ scan_and_report_cmd.add_argument("--report_file", type=str, help="report json file to write to")
+
+ scan_and_report_cmd.add_argument(
+ "--build_logger", type=str, help="Log to build logger with credentials from specified file")
+ scan_and_report_cmd.add_argument("--build_logger_url", type=str,
+ default=BUILD_LOGGER_DEFAULT_URL,
+ help="build logger url to log to")
+ scan_and_report_cmd.add_argument("--build_logger_task_id", type=str,
+ help="build logger task id")
+ scan_and_report_cmd.add_argument("--build_logger_local", action='store_true',
+ help="Log to local build logger, logs to disk by default")
+ scan_and_report_cmd.add_argument("--vulnerabilities_only", action='store_true',
+ help="Only check for security vulnerabilities")
+ scan_and_report_cmd.set_defaults(func=_scan_and_report_args)
+
+ args = parser.parse_args()
+
+ _test_version_info()
+
+ if args.debug:
+ logging.basicConfig(level=logging.DEBUG)
+ elif args.verbose:
+ logging.basicConfig(level=logging.INFO)
+
+ args.func(args)
+
+
+if __name__ == "__main__":
+ main()