1+ '''
2+ Created on Dec 19, 2018
3+ Updated on Sept 20, 2024
4+
5+ @author: gsnyder
6+ @contributor: smiths
7+
8+ Generate a CSV report for a given project-version and enhance with "File Paths", "How to Fix", and
9+ "References and Related Links"
10+ '''
11+
12+ import argparse
13+ import csv
14+ import io
15+ import json
16+ import logging
17+ import time
18+ import zipfile
19+ from blackduck .HubRestApi import HubInstance
20+ from requests .exceptions import MissingSchema
21+
22+ logging .basicConfig (
23+ level = logging .DEBUG ,
24+ format = "[%(asctime)s] {%(module)s:%(lineno)d} %(levelname)s - %(message)s"
25+ )
26+
27+ version_name_map = {
28+ 'version' : 'VERSION' ,
29+ 'scans' : 'CODE_LOCATIONS' ,
30+ 'components' : 'COMPONENTS' ,
31+ 'vulnerabilities' : 'SECURITY' ,
32+ 'source' : 'FILES' ,
33+ 'cryptography' : 'CRYPTO_ALGORITHMS' ,
34+ 'license_terms' : 'LICENSE_TERM_FULFILLMENT' ,
35+ 'component_additional_fields' : 'BOM_COMPONENT_CUSTOM_FIELDS' ,
36+ 'project_version_additional_fields' : 'PROJECT_VERSION_CUSTOM_FIELDS' ,
37+ 'vulnerability_matches' : 'VULNERABILITY_MATCH'
38+ }
39+
40+ all_reports = list (version_name_map .keys ())
41+
42+ parser = argparse .ArgumentParser ("A program to create reports for a given project-version" )
43+ parser .add_argument ("project_name" )
44+ parser .add_argument ("version_name" )
45+ parser .add_argument ("-z" , "--zip_file_name" , default = "reports.zip" )
46+ parser .add_argument ("-r" , "--reports" ,
47+ default = "," .join (all_reports ),
48+ help = f"Comma separated list (no spaces) of the reports to generate - { list (version_name_map .keys ())} . Default is all reports." ,
49+ type = lambda s : s .upper ())
50+ parser .add_argument ('--format' , default = 'CSV' , choices = ["CSV" ], help = "Report format - only CSV available for now" )
51+ parser .add_argument ('-t' , '--tries' , default = 5 , type = int , help = "How many times to retry downloading the report, i.e. wait for the report to be generated" )
52+ parser .add_argument ('-s' , '--sleep_time' , default = 30 , type = int , help = "The amount of time to sleep in-between (re-)tries to download the report" )
53+
54+ args = parser .parse_args ()
55+
56+ hub = HubInstance ()
57+
58+ class FailedReportDownload (Exception ):
59+ pass
60+
61+ def download_report (location , filename , retries = args .tries ):
62+ report_id = location .split ("/" )[- 1 ]
63+
64+ for attempt in range (retries ):
65+
66+ # Wait for 30 seconds before attempting to download
67+ print (f"Waiting 30 seconds before attempting to download..." )
68+ time .sleep (30 )
69+
70+ # Retries
71+ print (f"Attempt { attempt + 1 } of { retries } to retrieve report { report_id } " )
72+
73+ # Report Retrieval
74+ print (f"Retrieving generated report from { location } " )
75+ response = hub .download_report (report_id )
76+
77+ if response .status_code == 200 :
78+ with open (filename , "wb" ) as f :
79+ f .write (response .content )
80+ print (f"Successfully downloaded zip file to { filename } for report { report_id } " )
81+ return response .content
82+ else :
83+ print (f"Failed to retrieve report { report_id } " )
84+ if attempt < retries - 1 : # If it's not the last attempt
85+ wait_time = args .sleep_time
86+ print (f"Waiting { wait_time } seconds before retrying..." )
87+ time .sleep (wait_time )
88+ else :
89+ print (f"Maximum retries reached. Unable to download report." )
90+
91+ raise FailedReportDownload (f"Failed to retrieve report { report_id } after { retries } tries" )
92+
93+ def get_file_paths (hub , project_id , project_version_id , component_id , component_version_id , component_origin_id ):
94+ url = f"{ hub .get_urlbase ()} /api/projects/{ project_id } /versions/{ project_version_id } /components/{ component_id } /versions/{ component_version_id } /origins/{ component_origin_id } /matched-files"
95+ headers = {
96+ "Accept" : "application/vnd.blackducksoftware.bill-of-materials-6+json" ,
97+ "Authorization" : f"Bearer { hub .token } "
98+ }
99+
100+ logging .debug (f"Making API call to: { url } " )
101+
102+ try :
103+ response = hub .execute_get (url )
104+ if response .status_code == 200 :
105+ data = response .json ()
106+ file_paths = []
107+ for item in data .get ('items' , []):
108+ file_path = item .get ('filePath' , {})
109+ composite_path = file_path .get ('compositePathContext' , '' )
110+ if composite_path :
111+ file_paths .append (composite_path )
112+ return file_paths
113+ else :
114+ logging .error (f"Failed to fetch matched files. Status code: { response .status_code } " )
115+ return []
116+ except Exception as e :
117+ logging .error (f"Error making API request: { str (e )} " )
118+ return []
119+
120+ def get_vulnerability_details (hub , vulnerability_id ):
121+ url = f"{ hub .get_urlbase ()} /api/vulnerabilities/{ vulnerability_id } "
122+
123+ try :
124+ response = hub .execute_get (url )
125+ if response .status_code == 200 :
126+ data = response .json ()
127+ solution = data .get ('solution' , '' )
128+ references = []
129+ meta_data = data .get ('_meta' , {})
130+ links = meta_data .get ('links' , [])
131+ for link in links :
132+ references .append ({
133+ 'rel' : link .get ('rel' , '' ),
134+ 'href' : link .get ('href' , '' )
135+ })
136+ return solution , references
137+ else :
138+ logging .error (f"Failed to fetch vulnerability details. Status code: { response .status_code } " )
139+ return '' , []
140+ except Exception as e :
141+ logging .error (f"Error making API request for vulnerability details: { str (e )} " )
142+ return '' , []
143+
144+ def enhance_security_report (hub , zip_content , project_id , project_version_id ):
145+ logging .info (f"Enhancing security report for Project ID: { project_id } , Project Version ID: { project_version_id } " )
146+
147+ with zipfile .ZipFile (io .BytesIO (zip_content ), 'r' ) as zin :
148+ csv_files = [f for f in zin .namelist () if f .endswith ('.csv' )]
149+ for csv_file in csv_files :
150+ csv_content = zin .read (csv_file ).decode ('utf-8' )
151+ reader = csv .DictReader (io .StringIO (csv_content ))
152+
153+ # Count total rows
154+ total_rows = sum (1 for row in reader )
155+ reader = csv .DictReader (io .StringIO (csv_content )) # Reset reader
156+
157+ fieldnames = reader .fieldnames + ["File Paths" , "How to Fix" , "References and Related Links" ]
158+ output = io .StringIO ()
159+ writer = csv .DictWriter (output , fieldnames = fieldnames )
160+ writer .writeheader ()
161+
162+ processed_components = 0
163+ skipped_components = 0
164+
165+ for index , row in enumerate (reader , 1 ):
166+ print (f"\r Processing row { index } of { total_rows } ({ index / total_rows * 100 :.2f} %)" , end = '' , flush = True )
167+
168+ component_id = row .get ('Component id' , '' )
169+ component_version_id = row .get ('Version id' , '' )
170+ component_origin_id = row .get ('Origin id' , '' )
171+ vulnerability_id = row .get ('Vulnerability id' , '' )
172+
173+ if not all ([component_id , component_version_id , component_origin_id ]):
174+ logging .warning (f"Missing component information. Component ID: { component_id } , Component Version ID: { component_version_id } , Origin ID: { component_origin_id } " )
175+ skipped_components += 1
176+ file_paths = []
177+ else :
178+ file_paths = get_file_paths (hub , project_id , project_version_id , component_id , component_version_id , component_origin_id )
179+ processed_components += 1
180+
181+ if vulnerability_id :
182+ solution , references = get_vulnerability_details (hub , vulnerability_id )
183+ else :
184+ solution , references = '' , []
185+
186+ row ["File Paths" ] = '; ' .join (file_paths ) if file_paths else "No file paths available"
187+ row ["How to Fix" ] = solution
188+ row ["References and Related Links" ] = json .dumps (references )
189+
190+ writer .writerow (row )
191+
192+ print ("\n Processing complete." )
193+
194+ # Generate a unique filename for the enhanced report
195+ timestamp = time .strftime ("%Y%m%d-%H%M%S" )
196+ enhanced_filename = f"enhanced_security_report_{ timestamp } .csv"
197+
198+ # Update zip file with modified CSV
199+ with zipfile .ZipFile (args .zip_file_name , 'a' ) as zout :
200+ zout .writestr (enhanced_filename , output .getvalue ())
201+
202+ logging .info (f"Enhanced security report saved to { args .zip_file_name } " )
203+ logging .info (f"Processed components: { processed_components } " )
204+ logging .info (f"Skipped components: { skipped_components } " )
205+
206+ def main ():
207+ hub = HubInstance ()
208+
209+ project = hub .get_project_by_name (args .project_name )
210+
211+ if project :
212+ project_id = project ['_meta' ]['href' ].split ('/' )[- 1 ]
213+ logging .info (f"Project ID: { project_id } " )
214+
215+ version = hub .get_version_by_name (project , args .version_name )
216+ if version :
217+ project_version_id = version ['_meta' ]['href' ].split ('/' )[- 1 ]
218+ logging .info (f"Project Version ID: { project_version_id } " )
219+
220+ reports_l = [version_name_map .get (r .strip ().lower (), r .strip ()) for r in args .reports .split ("," )]
221+
222+ valid_reports = set (version_name_map .values ())
223+ invalid_reports = [r for r in reports_l if r not in valid_reports ]
224+ if invalid_reports :
225+ print (f"Error: Invalid report type(s): { ', ' .join (invalid_reports )} " )
226+ print (f"Valid report types are: { ', ' .join (valid_reports )} " )
227+ exit (1 )
228+
229+ response = hub .create_version_reports (version , reports_l , args .format )
230+
231+ if response .status_code == 201 :
232+ print (f"Successfully created reports ({ args .reports } ) for project { args .project_name } and version { args .version_name } " )
233+ location = response .headers ['Location' ]
234+ zip_content = download_report (location , args .zip_file_name )
235+
236+ if 'SECURITY' in reports_l :
237+ enhance_security_report (hub , zip_content , project_id , project_version_id )
238+ else :
239+ print (f"Failed to create reports for project { args .project_name } version { args .version_name } , status code returned { response .status_code } " )
240+ else :
241+ print (f"Did not find version { args .version_name } for project { args .project_name } " )
242+ else :
243+ print (f"Did not find project with name { args .project_name } " )
244+
245+ if __name__ == "__main__" :
246+ main ()
0 commit comments