Skip to content

Commit 59779e7

Browse files
committed
batch processing of SBOM reports
1 parent ac1fb59 commit 59779e7

File tree

1 file changed

+283
-0
lines changed

1 file changed

+283
-0
lines changed
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
'''
2+
Created on April 13, 2023
3+
@author: kumykov
4+
5+
Copyright (C) 2023 Synopsys, Inc.
6+
http://www.blackducksoftware.com/
7+
8+
Licensed to the Apache Software Foundation (ASF) under one
9+
or more contributor license agreements. See the NOTICE file
10+
distributed with this work for additional information
11+
regarding copyright ownership. The ASF licenses this file
12+
to you under the Apache License, Version 2.0 (the
13+
"License"); you may not use this file except in compliance
14+
with the License. You may obtain a copy of the License at
15+
16+
http://www.apache.org/licenses/LICENSE-2.0
17+
18+
Unless required by applicable law or agreed to in writing,
19+
software distributed under the License is distributed on an
20+
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
21+
KIND, either express or implied. See the License for the
22+
specific language governing permissions and limitations
23+
under the License.
24+
25+
This script will trigger SBOM report generation, wait for
26+
completion and then download SBOM report.
27+
Project version will be selected from an EXCEL file.
28+
Each row of a file is expected to contain a field for
29+
Project Name and Project Version.
30+
Script will iterate through the rows of a spreadsheet and
31+
process report generation sequentially, one at a time.
32+
33+
Requirements
34+
35+
- python3 version 3.8 or newer recommended
36+
- the following packages are used by the script and should be installed
37+
prior to use:
38+
argparse
39+
blackduck
40+
csv
41+
logging
42+
re
43+
openpyxl
44+
sys
45+
time
46+
- Blackduck instance
47+
- API token with sufficient privileges to perform project version phase
48+
change.
49+
50+
Install python packages with the following command:
51+
52+
pip3 install argparse blackduck csv logging re openpyxl sys time
53+
54+
Using
55+
56+
place the token into a file (token in this example) then execute:
57+
58+
python3 batch_generate_version_details_report -u https://blackduck-host -t token -nv -i excel-file-with-data
59+
60+
Projects and project versions that are listed in the file but are not
61+
present on the blackduck instance will be skipped.
62+
63+
Report filename will be generated as a combination of project name and version name
64+
65+
usage: Generate and download reports for projets in a spreadsheet
66+
67+
python3 batch_generate_sbom.py [-h] -u BASE_URL -t TOKEN_FILE -i INPUT_FILE [-nv]
68+
[-rt [{SPDX_22,CYCLONEDX_13,CYCLONEDX_14}]] [-tr TRIES] [-s SLEEP_TIME]
69+
70+
options:
71+
-h, --help show this help message and exit
72+
-u BASE_URL, --base-url BASE_URL
73+
Hub server URL e.g. https://your.blackduck.url
74+
-t TOKEN_FILE, --token-file TOKEN_FILE
75+
File containing access token
76+
-i INPUT_FILE, --input-file INPUT_FILE
77+
Project Name
78+
-nv, --no-verify Disable TLS certificate verification
79+
-rt [{SPDX_22,CYCLONEDX_13,CYCLONEDX_14}], --type [{SPDX_22,CYCLONEDX_13,CYCLONEDX_14}]
80+
Choose the type of SBOM report
81+
-tr TRIES, --tries TRIES
82+
How many times to retry downloading the report, i.e. wait for the report to be generated
83+
-s SLEEP_TIME, --sleep_time SLEEP_TIME
84+
The amount of time to sleep in-between (re-)tries to download the report
85+
86+
87+
'''
88+
89+
import csv
90+
import sys
91+
import argparse
92+
import logging
93+
import re
94+
import openpyxl
95+
import time
96+
97+
from blackduck import Client
98+
from blackduck.constants import VERSION_PHASES
99+
100+
logging.basicConfig(format='%(asctime)s:%(levelname)s:%(message)s', stream=sys.stderr, level=logging.DEBUG)
101+
logging.getLogger("requests").setLevel(logging.WARNING)
102+
logging.getLogger("urllib3").setLevel(logging.WARNING)
103+
logging.getLogger("blackduck").setLevel(logging.DEBUG)
104+
105+
# Values for the variables below should match corresponding column headers
106+
project_name_column = 'Project Name'
107+
project_version_column = 'Version Name'
108+
109+
summary_report = '''
110+
111+
Summary
112+
113+
'''
114+
115+
class FailedReportDownload(Exception):
116+
pass
117+
118+
def append_to_summary(message):
119+
global summary_report
120+
summary_report += message + '\n'
121+
122+
def process_csv_file(args):
123+
file = open(args.input_file)
124+
type(file)
125+
csvreader = csv.reader(file)
126+
project_name_idx = None
127+
project_version_idx = None
128+
for row in csvreader:
129+
row_number = csvreader.line_num
130+
if not (project_name_idx and project_version_idx):
131+
project_name_idx = row.index(project_name_column)
132+
project_version_idx = row.index(project_version_column)
133+
elif project_name_idx and project_version_idx:
134+
project_name = row[project_name_idx].strip() if project_name_idx < len(row) else ''
135+
version_name = row[project_version_idx].strip() if project_version_idx < len(row) else ''
136+
if project_name and version_name:
137+
logging.info(f"Processing row {row_number:4}: {row[project_name_idx]} : {row[project_version_idx]}")
138+
process_project_version(project_name, version_name, args)
139+
else:
140+
message = f"Processing row {row_number:}. Invalid data: Project '{project_name}' version '{version_name}', skipping"
141+
logging.info(message)
142+
append_to_summary(message)
143+
continue
144+
else:
145+
logging.info("Could not parse input file")
146+
sys.exit(1)
147+
148+
def process_excel_file(args):
149+
wb = openpyxl.load_workbook(args.input_file)
150+
ws = wb.active
151+
project_name_idx = None
152+
project_version_idx = None
153+
row_number = 0
154+
for row in ws.values:
155+
row_number += 1
156+
if not (project_name_idx and project_version_idx):
157+
project_name_idx = row.index(project_name_column)
158+
project_version_idx = row.index(project_version_column)
159+
elif project_name_idx and project_version_idx:
160+
project_name = row[project_name_idx] if project_name_idx < len(row) else ''
161+
version_name = row[project_version_idx] if project_version_idx < len(row) else ''
162+
if project_name and version_name:
163+
logging.info(f"Processing row {row_number:4}: {row[project_name_idx]} : {row[project_version_idx]}")
164+
process_project_version(project_name.strip(), version_name.strip(), args)
165+
else:
166+
message = f"Processing row {row_number:}. Invalid data: Project '{project_name}' version '{version_name}', skipping"
167+
logging.info(message)
168+
append_to_summary(message)
169+
continue
170+
else:
171+
logging.info("Could not parse input file")
172+
sys.exit(1)
173+
174+
def download_report(location, filename, retries, sleep_time):
175+
report_id = location.split("/")[-1]
176+
base_url = bd.base_url if bd.base_url.endswith("/") else bd.base_url + "/"
177+
download_url = f"{base_url}api/reports/{report_id}"
178+
179+
logging.info(f"Retrieving report list for {location}")
180+
181+
if retries:
182+
response = bd.session.get(location)
183+
report_status = response.json().get('status', 'Not Ready')
184+
if response.status_code == 200 and report_status == 'COMPLETED':
185+
response = bd.session.get(download_url, headers={'Content-Type': 'application/zip', 'Accept':'application/zip'})
186+
if response.status_code == 200:
187+
with open(filename, "wb") as f:
188+
f.write(response.content)
189+
logging.info(f"Successfully downloaded zip file to {filename} for report {report_id}")
190+
else:
191+
logging.error(f"Failed to download report")
192+
else:
193+
retries -= 1
194+
logging.debug(f"Failed to retrieve report {report_id}, report status: {report_status}")
195+
logging.debug(f"Will retry {retries} more times. Sleeping for {sleep_time} second(s)")
196+
time.sleep(sleep_time)
197+
download_report(location, filename, retries, sleep_time)
198+
else:
199+
raise FailedReportDownload(f"Failed to retrieve report {report_id} after multiple retries")
200+
201+
def process_project_version(project_name, version_name, args):
202+
params = {
203+
'q': [f"name:{project_name}"]
204+
}
205+
try:
206+
projects = [p for p in bd.get_resource('projects', params=params) if p['name'] == project_name]
207+
assert len(projects) == 1, f"There should be one, and only one project named {project_name}. We found {len(projects)}"
208+
project = projects[0]
209+
except AssertionError:
210+
message = f"Project named '{project_name}' not found. Skipping"
211+
logging.warning(message)
212+
append_to_summary(message)
213+
return
214+
215+
params = {
216+
'q': [f"versionName:{version_name}"]
217+
}
218+
219+
try:
220+
versions = [v for v in bd.get_resource('versions', project, params=params) if v['versionName'] == version_name]
221+
assert len(versions) == 1, f"There should be one, and only one version named {version_name}. We found {len(versions)}"
222+
version = versions[0]
223+
except AssertionError:
224+
message = f"Version name '{version_name}' for project {project_name} was not found, skipping"
225+
logging.warning(message)
226+
append_to_summary(message)
227+
return
228+
logging.debug(f"Found {project['name']}:{version['versionName']}")
229+
230+
post_data = {
231+
'reportFormat': "JSON",
232+
'reportType': 'SBOM',
233+
'sbomType': args.type,
234+
}
235+
sbom_reports_url = version['_meta']['href'] + "/sbom-reports"
236+
237+
r = bd.session.post(sbom_reports_url, json=post_data)
238+
r.raise_for_status()
239+
location = r.headers.get('Location')
240+
assert location, "Hmm, this does not make sense. If we successfully created a report then there needs to be a location where we can get it from"
241+
242+
logging.debug(f"Created SBOM report of type {args.type} for project {project_name}, version {version_name} at location {location}")
243+
report_file_name = project_name + "-" + version_name + "-sbom.zip"
244+
download_report(location, sanitize_filename(report_file_name), args.tries, args.sleep_time)
245+
246+
def sanitize_filename(filename):
247+
forbidden = '/<>:"\|?*'
248+
for c in forbidden:
249+
filename = filename.replace(c,'-')
250+
return filename
251+
252+
253+
def parse_command_args():
254+
255+
parser = argparse.ArgumentParser("Generate and download reports for projets in a spreadsheet")
256+
parser.add_argument("-u", "--base-url", required=True, help="Hub server URL e.g. https://your.blackduck.url")
257+
parser.add_argument("-t", "--token-file", required=True, help="File containing access token")
258+
parser.add_argument("-i", "--input-file", required=True, help="Project Name")
259+
parser.add_argument("-nv", "--no-verify", action='store_false', help="Disable TLS certificate verification")
260+
parser.add_argument("-rt", "--type", type=str, nargs='?', default="SPDX_22", choices=["SPDX_22", "CYCLONEDX_13", "CYCLONEDX_14"], help="Choose the type of SBOM report")
261+
parser.add_argument('-tr', '--tries', default=30, type=int, help="How many times to retry downloading the report, i.e. wait for the report to be generated")
262+
parser.add_argument('-s', '--sleep_time', default=10, type=int, help="The amount of time to sleep in-between (re-)tries to download the report")
263+
264+
return parser.parse_args()
265+
266+
def main():
267+
args = parse_command_args()
268+
with open(args.token_file, 'r') as tf:
269+
access_token = tf.readline().strip()
270+
global bd
271+
bd = Client(base_url=args.base_url, token=access_token, verify=args.no_verify, timeout=60.0, retries=4)
272+
273+
if re.match(".+xlsx?$", args.input_file):
274+
logging.info(f"Processing EXCEL file {args.input_file}")
275+
process_excel_file(args)
276+
else:
277+
logging.info(f"Processing CSV file {args.input_file}")
278+
process_csv_file(args)
279+
280+
print (summary_report)
281+
282+
if __name__ == "__main__":
283+
sys.exit(main())

0 commit comments

Comments
 (0)