|
| 1 | +''' |
| 2 | +Created on June 25, 2024 |
| 3 | +
|
| 4 | +@author: dnichol and kumykov |
| 5 | +
|
| 6 | +Generate version detail reports (source and components) and consolidate information on source matches, with license |
| 7 | +and component matched. Removes matches found underneith other matched components in the source tree (configurable). |
| 8 | +
|
| 9 | +Copyright (C) 2023 Synopsys, Inc. |
| 10 | +http://www.synopsys.com/ |
| 11 | +
|
| 12 | +Licensed to the Apache Software Foundation (ASF) under one |
| 13 | +or more contributor license agreements. See the NOTICE file |
| 14 | +distributed with this work for additional information |
| 15 | +regarding copyright ownership. The ASF licenses this file |
| 16 | +to you under the Apache License, Version 2.0 (the |
| 17 | +"License"); you may not use this file except in compliance |
| 18 | +with the License. You may obtain a copy of the License at |
| 19 | +
|
| 20 | +http://www.apache.org/licenses/LICENSE-2.0 |
| 21 | +
|
| 22 | +Unless required by applicable law or agreed to in writing, |
| 23 | +software distributed under the License is distributed on an |
| 24 | +"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| 25 | +KIND, either express or implied. See the License for the |
| 26 | +specific language governing permissions and limitations |
| 27 | +under the License. |
| 28 | +''' |
| 29 | + |
| 30 | +import argparse |
| 31 | +import logging |
| 32 | +import sys |
| 33 | +import os |
| 34 | +import re |
| 35 | +import time |
| 36 | +import subprocess |
| 37 | +import json |
| 38 | +import traceback |
| 39 | +import copy |
| 40 | +import ijson |
| 41 | +from blackduck import Client |
| 42 | +from zipfile import ZipFile |
| 43 | + |
| 44 | +program_description = \ |
| 45 | +'''Generate version detail reports (source and components) and consolidate information on source matches, with license |
| 46 | +and component matched. Removes matches found underneith other matched components in the source tree (configurable). |
| 47 | +
|
| 48 | +This script assumes a project version exists and has scans associated with it (i.e. the project is not scanned as part of this process). |
| 49 | +
|
| 50 | +Config file: |
| 51 | +API Token and Black Duck URL need to be placed in the .restconfig.json file which must be placed in the same folder where this script resides. |
| 52 | + { |
| 53 | + "baseurl": "https://hub-hostname", |
| 54 | + "api_token": "<API token goes here>", |
| 55 | + "insecure": true or false <Default is false>, |
| 56 | + "debug": true or false <Default is false> |
| 57 | + } |
| 58 | +
|
| 59 | +Remarks: |
| 60 | +This script uses 3rd party PyPI package "ijson". This package must be installed. |
| 61 | +''' |
| 62 | + |
| 63 | +# BD report general |
| 64 | +BLACKDUCK_REPORT_MEDIATYPE = "application/vnd.blackducksoftware.report-4+json" |
| 65 | +blackduck_report_download_api = "/api/projects/{projectId}/versions/{projectVersionId}/reports/{reportId}/download" |
| 66 | +# BD version details report |
| 67 | +blackduck_create_version_report_api = "/api/versions/{projectVersionId}/reports" |
| 68 | +blackduck_version_report_filename = "./blackduck_version_report_for_{projectVersionId}.zip" |
| 69 | +# Consolidated report |
| 70 | +BLACKDUCK_VERSION_MEDIATYPE = "application/vnd.blackducksoftware.status-4+json" |
| 71 | +BLACKDUCK_VERSION_API = "/api/current-version" |
| 72 | +REPORT_DIR = "./blackduck_component_source_report" |
| 73 | +# Retries to wait for BD report creation. RETRY_LIMIT can be overwritten by the script parameter. |
| 74 | +RETRY_LIMIT = 30 |
| 75 | +RETRY_TIMER = 30 |
| 76 | + |
| 77 | +def log_config(debug): |
| 78 | + if debug: |
| 79 | + logging.basicConfig(format='%(asctime)s:%(levelname)s:%(module)s: %(message)s', stream=sys.stderr, level=logging.DEBUG) |
| 80 | + else: |
| 81 | + logging.basicConfig(format='%(asctime)s:%(levelname)s:%(module)s: %(message)s', stream=sys.stderr, level=logging.INFO) |
| 82 | + logging.getLogger("requests").setLevel(logging.WARNING) |
| 83 | + logging.getLogger("urllib3").setLevel(logging.WARNING) |
| 84 | + logging.getLogger("blackduck").setLevel(logging.WARNING) |
| 85 | + |
| 86 | +def parse_parameter(): |
| 87 | + parser = argparse.ArgumentParser(description=program_description, formatter_class=argparse.RawTextHelpFormatter) |
| 88 | + parser.add_argument("project", |
| 89 | + metavar="project", |
| 90 | + type=str, |
| 91 | + help="Provide the BlackDuck project name.") |
| 92 | + parser.add_argument("version", |
| 93 | + metavar="version", |
| 94 | + type=str, |
| 95 | + help="Provide the BlackDuck project version name.") |
| 96 | + parser.add_argument("-kh", |
| 97 | + "--keep_hierarchy", |
| 98 | + action='store_true', |
| 99 | + help="Set to keep all entries in the sources report. Will not remove components found under others.") |
| 100 | + parser.add_argument("-rr", |
| 101 | + "--report_retries", |
| 102 | + metavar="", |
| 103 | + type=int, |
| 104 | + default=RETRY_LIMIT, |
| 105 | + help="Retries for receiving the generated BlackDuck report. Generating copyright report tends to take longer minutes.") |
| 106 | + parser.add_argument("-t", |
| 107 | + "--timeout", |
| 108 | + metavar="", |
| 109 | + type=int, |
| 110 | + default=15, |
| 111 | + help="Timeout for REST-API. Some API may take longer than the default 15 seconds") |
| 112 | + parser.add_argument("-r", |
| 113 | + "--retries", |
| 114 | + metavar="", |
| 115 | + type=int, |
| 116 | + default=3, |
| 117 | + help="Retries for REST-API. Some API may need more retries than the default 3 times") |
| 118 | + return parser.parse_args() |
| 119 | + |
| 120 | +def get_bd_project_data(hub_client, project_name, version_name): |
| 121 | + """ Get and return project ID, version ID. """ |
| 122 | + project_id = "" |
| 123 | + for project in hub_client.get_resource("projects"): |
| 124 | + if project['name'] == project_name: |
| 125 | + project_id = (project['_meta']['href']).split("projects/", 1)[1] |
| 126 | + break |
| 127 | + if project_id == "": |
| 128 | + sys.exit(f"No project for {project_name} was found!") |
| 129 | + version_id = codelocations = "" |
| 130 | + for version in hub_client.get_resource("versions", project): |
| 131 | + if version['versionName'] == version_name: |
| 132 | + version_id = (version['_meta']['href']).split("versions/", 1)[1] |
| 133 | + break |
| 134 | + if version_id == "": |
| 135 | + sys.exit(f"No project version for {version_name} was found!") |
| 136 | + |
| 137 | + return project_id, version_id |
| 138 | + |
| 139 | +def report_create(hub_client, url, body): |
| 140 | + """ |
| 141 | + Request BlackDuck to create report. Requested report is included in the request payload. |
| 142 | + """ |
| 143 | + res = hub_client.session.post(url, headers={'Content-Type': BLACKDUCK_REPORT_MEDIATYPE}, json=body) |
| 144 | + if res.status_code != 201: |
| 145 | + sys.exit(f"BlackDuck report creation failed with status {res.status_code}!") |
| 146 | + return res.headers['Location'] # return report_url |
| 147 | + |
| 148 | +def report_download(hub_client, report_url, project_id, version_id, retries): |
| 149 | + """ |
| 150 | + Download the generated report after the report completion. We will retry until reaching the retry-limit. |
| 151 | + """ |
| 152 | + while retries: |
| 153 | + res = hub_client.session.get(report_url, headers={'Accept': BLACKDUCK_REPORT_MEDIATYPE}) |
| 154 | + if res.status_code == 200 and (json.loads(res.content))['status'] == "COMPLETED": |
| 155 | + report_id = report_url.split("reports/", 1)[1] |
| 156 | + download_url = (((blackduck_report_download_api.replace("{projectId}", project_id)) |
| 157 | + .replace("{projectVersionId}", version_id)) |
| 158 | + .replace("{reportId}", report_id)) |
| 159 | + res = hub_client.session.get(download_url, |
| 160 | + headers={'Content-Type': 'application/zip', 'Accept':'application/zip'}) |
| 161 | + if res.status_code != 200: |
| 162 | + sys.exit(f"BlackDuck report download failed with status {res.status_code} for {download_url}!") |
| 163 | + return res.content |
| 164 | + elif res.status_code != 200: |
| 165 | + sys.exit(f"BlackDuck report creation not completed successfully with status {res.status_code}") |
| 166 | + else: |
| 167 | + retries -= 1 |
| 168 | + logging.info(f"Waiting for the report generation for {report_url} with the remaining retries {retries} times.") |
| 169 | + time.sleep(RETRY_TIMER) |
| 170 | + sys.exit(f"BlackDuck report for {report_url} was not generated after retries {RETRY_TIMER} sec * {retries} times!") |
| 171 | + |
| 172 | +def get_version_detail_report(hub_client, project_id, version_id, retries): |
| 173 | + """ Create and get BOM component and BOM source file report in json. """ |
| 174 | + create_version_url = blackduck_create_version_report_api.replace("{projectVersionId}", version_id) |
| 175 | + body = { |
| 176 | + 'reportFormat' : 'JSON', |
| 177 | + 'locale' : 'en_US', |
| 178 | + 'versionId' : f'{version_id}', |
| 179 | + 'categories' : [ 'COMPONENTS', 'FILES' ] # Generating "project version" report including components and files |
| 180 | + } |
| 181 | + report_url = report_create(hub_client, create_version_url, body) |
| 182 | + # Zipped report content is received and write the content to a local zip file |
| 183 | + content = report_download(hub_client, report_url, project_id, version_id, retries) |
| 184 | + output_file = blackduck_version_report_filename.replace("{projectVersionId}", version_id) |
| 185 | + with open(output_file, "wb") as f: |
| 186 | + f.write(content) |
| 187 | + return output_file |
| 188 | + |
| 189 | +def get_blackduck_version(hub_client): |
| 190 | + url = hub_client.base_url + BLACKDUCK_VERSION_API |
| 191 | + res = hub_client.session.get(url) |
| 192 | + if res.status_code == 200 and res.content: |
| 193 | + return json.loads(res.content)['version'] |
| 194 | + else: |
| 195 | + sys.exit(f"Get BlackDuck version failed with status {res.status_code}") |
| 196 | + |
| 197 | +def generate_file_report(hub_client, project_id, version_id, keep_hierarchy, retries): |
| 198 | + """ |
| 199 | + Create a consolidated file report from BlackDuck project version source and components reports. |
| 200 | + Remarks: |
| 201 | + """ |
| 202 | + if not os.path.exists(REPORT_DIR): |
| 203 | + os.makedirs(REPORT_DIR) |
| 204 | + |
| 205 | + # Report body - Component BOM, file BOM with Discoveries data |
| 206 | + version_report_zip = get_version_detail_report(hub_client, project_id, version_id, retries) |
| 207 | + with ZipFile(f"./{version_report_zip}", "r") as vzf: |
| 208 | + vzf.extractall() |
| 209 | + for i, unzipped_version in enumerate(vzf.namelist()): |
| 210 | + if re.search(r"\bversion.+json\b", unzipped_version) is not None: |
| 211 | + break |
| 212 | + if i + 1 >= len(vzf.namelist()): |
| 213 | + sys.exit(f"Version detail file not found in the downloaded report: {version_report_zip}!") |
| 214 | + |
| 215 | + # Report body - Component BOM report |
| 216 | + with open(f"./{unzipped_version}", "r") as uvf: |
| 217 | + for i, comp_bom in enumerate(ijson.items(uvf, 'aggregateBomViewEntries.item')): |
| 218 | + logging.info(f"{comp_bom['componentName']}") |
| 219 | + logging.info(f"Number of the reported components {i+1}") |
| 220 | + |
| 221 | + |
| 222 | +def main(): |
| 223 | + args = parse_parameter() |
| 224 | + debug = 0 |
| 225 | + try: |
| 226 | + if args.project == "": |
| 227 | + sys.exit("Please set BlackDuck project name!") |
| 228 | + if args.version == "": |
| 229 | + sys.exit("Please set BlackDuck project version name!") |
| 230 | + |
| 231 | + with open(".restconfig.json", "r") as f: |
| 232 | + config = json.load(f) |
| 233 | + # Remove last slash if there is, otherwise REST API may fail. |
| 234 | + if re.search(r".+/$", config['baseurl']): |
| 235 | + bd_url = config['baseurl'][:-1] |
| 236 | + else: |
| 237 | + bd_url = config['baseurl'] |
| 238 | + bd_token = config['api_token'] |
| 239 | + bd_insecure = not config['insecure'] |
| 240 | + if config['debug']: |
| 241 | + debug = 1 |
| 242 | + |
| 243 | + log_config(debug) |
| 244 | + |
| 245 | + hub_client = Client(token=bd_token, |
| 246 | + base_url=bd_url, |
| 247 | + verify=bd_insecure, |
| 248 | + timeout=args.timeout, |
| 249 | + retries=args.retries) |
| 250 | + |
| 251 | + project_id, version_id = get_bd_project_data(hub_client, args.project, args.version) |
| 252 | + |
| 253 | + generate_file_report(hub_client, |
| 254 | + project_id, |
| 255 | + version_id, |
| 256 | + args.keep_hierarchy, |
| 257 | + args.report_retries |
| 258 | + ) |
| 259 | + |
| 260 | + except (Exception, BaseException) as err: |
| 261 | + logging.error(f"Exception by {str(err)}. See the stack trace") |
| 262 | + traceback.print_exc() |
| 263 | + |
| 264 | +if __name__ == '__main__': |
| 265 | + sys.exit(main()) |
0 commit comments