owasp-depscan 5.5.0__py3-none-any.whl → 6.0.0a3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. depscan/__init__.py +8 -0
  2. depscan/cli.py +719 -827
  3. depscan/cli_options.py +302 -0
  4. depscan/lib/audit.py +3 -1
  5. depscan/lib/bom.py +387 -289
  6. depscan/lib/config.py +86 -337
  7. depscan/lib/explainer.py +389 -101
  8. depscan/lib/license.py +11 -10
  9. depscan/lib/logger.py +65 -17
  10. depscan/lib/package_query/__init__.py +0 -0
  11. depscan/lib/package_query/cargo_pkg.py +124 -0
  12. depscan/lib/package_query/metadata.py +170 -0
  13. depscan/lib/package_query/npm_pkg.py +345 -0
  14. depscan/lib/package_query/pkg_query.py +195 -0
  15. depscan/lib/package_query/pypi_pkg.py +113 -0
  16. depscan/lib/tomlparse.py +116 -0
  17. depscan/lib/utils.py +34 -188
  18. owasp_depscan-6.0.0a3.dist-info/METADATA +388 -0
  19. {owasp_depscan-5.5.0.dist-info → owasp_depscan-6.0.0a3.dist-info}/RECORD +28 -25
  20. {owasp_depscan-5.5.0.dist-info → owasp_depscan-6.0.0a3.dist-info}/WHEEL +1 -1
  21. vendor/choosealicense.com/_licenses/cern-ohl-p-2.0.txt +1 -1
  22. vendor/choosealicense.com/_licenses/cern-ohl-s-2.0.txt +1 -1
  23. vendor/choosealicense.com/_licenses/cern-ohl-w-2.0.txt +2 -2
  24. vendor/choosealicense.com/_licenses/mit-0.txt +1 -1
  25. vendor/spdx/json/licenses.json +904 -677
  26. depscan/lib/analysis.py +0 -1554
  27. depscan/lib/csaf.py +0 -1860
  28. depscan/lib/normalize.py +0 -312
  29. depscan/lib/orasclient.py +0 -142
  30. depscan/lib/pkg_query.py +0 -532
  31. owasp_depscan-5.5.0.dist-info/METADATA +0 -580
  32. {owasp_depscan-5.5.0.dist-info → owasp_depscan-6.0.0a3.dist-info}/entry_points.txt +0 -0
  33. {owasp_depscan-5.5.0.dist-info → owasp_depscan-6.0.0a3.dist-info/licenses}/LICENSE +0 -0
  34. {owasp_depscan-5.5.0.dist-info → owasp_depscan-6.0.0a3.dist-info}/top_level.txt +0 -0
depscan/lib/bom.py CHANGED
@@ -1,46 +1,22 @@
1
- import json
2
1
  import os
3
- import shlex
4
2
  import shutil
5
- import subprocess
6
3
  import sys
4
+ import uuid
5
+ from collections import defaultdict
6
+ from datetime import datetime, timezone
7
7
  from urllib.parse import unquote_plus
8
8
 
9
- import httpx
9
+ from custom_json_diff.lib.utils import json_load, json_dump
10
10
  from defusedxml.ElementTree import parse
11
-
12
- from depscan.lib.logger import LOG
13
- from depscan.lib.utils import cleanup_license_string, find_files
14
-
15
- headers = {
16
- "Content-Type": "application/json",
17
- "Accept-Encoding": "gzip",
18
- }
19
-
20
-
21
- def exec_tool(args, cwd=None, stdout=subprocess.PIPE):
22
- """
23
- Convenience method to invoke cli tools
24
-
25
- :param args: Command line arguments
26
- :param cwd: Working directory
27
- :param stdout: Specifies stdout of command
28
- """
29
- try:
30
- LOG.debug('⚡︎ Executing "%s"', " ".join(args))
31
- cp = subprocess.run(
32
- args,
33
- stdout=stdout,
34
- stderr=subprocess.STDOUT,
35
- cwd=cwd,
36
- env=os.environ.copy(),
37
- shell=sys.platform == "win32",
38
- encoding="utf-8",
39
- check=False,
40
- )
41
- LOG.debug(cp.stdout)
42
- except Exception as e:
43
- LOG.exception(e)
11
+ from xbom_lib.blint import BlintGenerator
12
+ from xbom_lib.cdxgen import (
13
+ CdxgenGenerator,
14
+ CdxgenImageBasedGenerator,
15
+ CdxgenServerGenerator,
16
+ )
17
+ from depscan.lib.logger import LOG, SPINNER, console
18
+ from depscan.lib.utils import cleanup_license_string
19
+ from typing import Dict, Optional
44
20
 
45
21
 
46
22
  def parse_bom_ref(bomstr, licenses=None):
@@ -95,15 +71,13 @@ def get_licenses(ele):
95
71
  """
96
72
  license_list = []
97
73
  namespace = "{http://cyclonedx.org/schema/bom/1.5}"
98
- for data in ele.findall(
99
- f"{namespace}licenses/{namespace}license/{namespace}id"
100
- ):
74
+ for data in ele.findall(f"{namespace}licenses/{namespace}license/{namespace}id"):
101
75
  license_list.append(data.text)
102
76
  if not license_list:
103
77
  for data in ele.findall(
104
78
  f"{namespace}licenses/{namespace}license/{namespace}name"
105
79
  ):
106
- if data and data.text:
80
+ if data is not None and data.text:
107
81
  ld_list = [data.text]
108
82
  if "http" in data.text:
109
83
  ld_list = [
@@ -161,40 +135,49 @@ def get_pkg_list_json(jsonfile):
161
135
  return List of dicts representing extracted packages
162
136
  """
163
137
  pkgs = []
164
- with open(jsonfile, encoding="utf-8") as fp:
165
- try:
166
- bom_data = json.load(fp)
167
- if bom_data and bom_data.get("components"):
168
- for comp in bom_data.get("components"):
169
- licenses = []
170
- vendor = comp.get("group")
171
- if not vendor:
172
- vendor = ""
173
- if comp.get("licenses"):
174
- for lic in comp.get("licenses"):
175
- license_obj = lic
176
- # licenses has list of dict with either license
177
- # or expression as key Only license is supported
178
- # for now
179
- if lic.get("license"):
180
- license_obj = lic.get("license")
181
- if license_obj.get("id"):
182
- licenses.append(license_obj.get("id"))
183
- elif license_obj.get("name"):
184
- licenses.append(
185
- cleanup_license_string(
186
- license_obj.get("name")
187
- )
188
- )
189
- pkgs.append(
190
- {**comp, "vendor": vendor, "licenses": licenses}
191
- )
192
- except Exception:
193
- # Ignore json errors
194
- pass
138
+ if bom_data := json_load(jsonfile, log=LOG):
139
+ if bom_data.get("components"):
140
+ for comp in bom_data.get("components", []):
141
+ licenses, vendor, url = get_license_vendor_url(comp)
142
+ pkgs.append(
143
+ {**comp, "vendor": vendor, "licenses": licenses, "url": url}
144
+ )
195
145
  return pkgs
196
146
 
197
147
 
148
+ def get_license_vendor_url(comp):
149
+ licenses = []
150
+ vendor = comp.get("group") or ""
151
+ if comp.get("licenses"):
152
+ for lic in comp.get("licenses"):
153
+ license_obj = lic
154
+ if lic.get("license"):
155
+ license_obj = lic.get("license")
156
+ if license_obj.get("id"):
157
+ licenses.append(license_obj.get("id"))
158
+ elif license_obj.get("name"):
159
+ licenses.append(cleanup_license_string(license_obj.get("name")))
160
+ url = ""
161
+ for aref in comp.get("externalReferences", []):
162
+ if aref.get("type") in (
163
+ "vcs",
164
+ "issue-tracker",
165
+ "website",
166
+ "bom",
167
+ "source-distribution",
168
+ "distribution",
169
+ "distribution-intake",
170
+ "build-system",
171
+ "model-card",
172
+ "evidence",
173
+ "formulation",
174
+ ):
175
+ url = aref.get("url", "")
176
+ break
177
+ return licenses, vendor, url
178
+
179
+
180
+ # Unused
198
181
  def get_pkg_list(xmlfile):
199
182
  """Method to parse the bom xml file and convert into packages list
200
183
 
@@ -232,247 +215,362 @@ def get_pkg_by_type(pkg_list, pkg_type):
232
215
  if not pkg_list:
233
216
  return []
234
217
  return [
235
- pkg
236
- for pkg in pkg_list
237
- if pkg.get("purl", "").startswith("pkg:" + pkg_type)
218
+ pkg for pkg in pkg_list if pkg.get("purl", "").startswith("pkg:" + pkg_type)
238
219
  ]
239
220
 
240
221
 
241
- def resource_path(relative_path):
222
+ def create_bom(bom_file, src_dir=".", options=None):
242
223
  """
243
- Determine the absolute path of a resource file based on its relative path.
224
+ Method to create BOM file by executing cdxgen command
244
225
 
245
- :param relative_path: Relative path of the resource file.
246
- :return: Absolute path of the resource file
226
+ :param bom_file: BOM file
227
+ :param src_dir: Source directory
228
+ :param options: Additional options for generating the BOM file.
229
+ :returns: True if the command was executed. False if the executable was
230
+ not found.
247
231
  """
248
- try:
249
- base_path = sys._MEIPASS
250
- except Exception:
251
- base_path = os.path.dirname(__file__)
252
- return os.path.join(base_path, relative_path)
253
-
254
-
255
- def exec_cdxgen(use_bin=True):
256
- if use_bin:
257
- cdxgen_cmd = os.environ.get("CDXGEN_CMD", "cdxgen")
258
- if not shutil.which(cdxgen_cmd):
259
- local_bin = resource_path(
260
- os.path.join(
261
- "local_bin",
262
- "cdxgen.exe" if sys.platform == "win32" else "cdxgen",
263
- )
232
+ if not options:
233
+ options = {}
234
+ # Get the various options and filenames
235
+ techniques = options.get("techniques") or []
236
+ lifecycles = options.get("lifecycles") or []
237
+ project_type_list = options.get("project_type") or []
238
+ bom_engine = options.get("bom_engine", "")
239
+ lifecycle_analysis_mode = options.get("lifecycle_analysis_mode", False)
240
+ # Detect if blint needs to be used for the given project type, technique, and lifecycle.
241
+ # For binaries, generate an sbom with blint directly
242
+ if (
243
+ bom_engine == "BlintGenerator"
244
+ or "binary-analysis" in techniques
245
+ or "post-build" in lifecycles
246
+ or any([t in ("binary", "apk") for t in project_type_list])
247
+ ):
248
+ return create_blint_bom(bom_file, src_dir, options=options)
249
+ cdxgen_server = options.get("cdxgen_server")
250
+ cdxgen_lib = CdxgenGenerator
251
+
252
+ # Should we call cdxgen server
253
+ if cdxgen_server or bom_engine == "CdxgenServerGenerator":
254
+ if not cdxgen_server:
255
+ LOG.error(
256
+ "Pass the `--cdxgen-server` argument to use the cdxgen server for BOM generation. Alternatively, use `--bom-engine auto` or `--bom-engine CdxgenGenerator`."
264
257
  )
265
- if not os.path.exists(local_bin):
266
- LOG.warning(
267
- "%s command not found. Please install using npm install "
268
- "@cyclonedx/cdxgen or set PATH variable",
269
- cdxgen_cmd,
270
- )
271
- return False
272
- try:
273
- cdxgen_cmd = local_bin
274
- # Set the plugins directory as an environment variable
275
- os.environ["CDXGEN_PLUGINS_DIR"] = resource_path("local_bin")
276
- return cdxgen_cmd
277
- except Exception:
278
- return None
279
- else:
280
- return cdxgen_cmd
258
+ return False
259
+ cdxgen_lib = CdxgenServerGenerator
281
260
  else:
282
- lbin = os.getenv("APPDATA") if sys.platform == "win32" else "local_bin"
283
- local_bin = resource_path(
284
- os.path.join(
285
- f"{lbin}\\npm\\" if sys.platform == "win32" else "local_bin",
286
- "cdxgen" if sys.platform != "win32" else "cdxgen.cmd",
287
- )
288
- )
289
- if not os.path.exists(local_bin):
290
- LOG.warning(
291
- "%s command not found. Please install using npm install "
292
- "@cyclonedx/cdxgen or set PATH variable",
293
- local_bin,
294
- )
295
- return None
296
- try:
297
- cdxgen_cmd = local_bin
298
- # Set the plugins directory as an environment variable
299
- os.environ["CDXGEN_PLUGINS_DIR"] = (
300
- resource_path("local_bin")
301
- if sys.platform != "win32"
302
- else resource_path(
303
- os.path.join(
304
- lbin,
305
- "\\npm\\node_modules\\@cyclonedx\\cdxgen\\node_modules\\@cyclonedx\\cdxgen-plugins-bin\\plugins",
261
+ # Prefer the new image based generators if docker command is available in auto mode
262
+ if bom_engine == "CdxgenImageBasedGenerator":
263
+ cdxgen_lib = CdxgenImageBasedGenerator
264
+ elif bom_engine == "auto":
265
+ # Prefer local CLI while scanning container images
266
+ if any(
267
+ [
268
+ t in ("docker", "podman", "oci", "os", "hardware")
269
+ for t in project_type_list
270
+ ]
271
+ ):
272
+ cdxgen_lib = CdxgenGenerator
273
+ if lifecycle_analysis_mode:
274
+ LOG.warning(
275
+ "Lifecycle analysis is not supported for oci and os project types."
306
276
  )
307
- )
277
+ lifecycle_analysis_mode = True
278
+ elif (
279
+ shutil.which(os.getenv("DOCKER_CMD", "docker"))
280
+ and sys.platform != "win32"
281
+ ):
282
+ cdxgen_lib = CdxgenImageBasedGenerator
283
+ # We now have the cdxgen library to use.
284
+ # For lifecycle analysis, we need to generate multiple BOM files
285
+ if lifecycle_analysis_mode:
286
+ return create_lifecycle_boms(cdxgen_lib, src_dir, options)
287
+ # Invoke the cdxgen library directly
288
+ with console.status(
289
+ f"Generating BOM for the source '{src_dir}' with cdxgen.", spinner=SPINNER
290
+ ):
291
+ bom_result = cdxgen_lib(
292
+ src_dir, bom_file, logger=LOG, options=options
293
+ ).generate()
294
+ if not bom_result.success:
295
+ LOG.info(
296
+ "The cdxgen invocation was unsuccessful. Try generating the BOM separately."
308
297
  )
309
- return cdxgen_cmd
310
- except Exception:
311
- return None
298
+ LOG.debug(bom_result.command_output)
299
+ return bom_result.success and os.path.exists(bom_file)
312
300
 
313
301
 
314
- def create_bom(project_type, bom_file, src_dir=".", deep=False, options={}):
302
+ def create_blint_bom(
303
+ bom_file: str, src_dir: str = ".", options: Optional[Dict] = None
304
+ ) -> bool:
315
305
  """
316
- Method to create BOM file by executing cdxgen command
306
+ Method to create BOM file by using blint
317
307
 
318
- :param project_type: Project type
319
308
  :param bom_file: BOM file
320
309
  :param src_dir: Source directory
321
- :param deep: A boolean flag indicating whether to perform a deep scan.
322
310
  :param options: Additional options for generating the BOM file.
323
- :returns: True if the command was executed. False if the executable was
324
- not found.
311
+ :returns: True if the bom was generated successfully. False otherwise.
325
312
  """
326
- cdxgen_server = options.get("cdxgen_server")
327
- # Generate SBOM by calling cdxgen server
328
- if cdxgen_server:
329
- # Fallback to universal if no project type was provided
330
- if not project_type:
331
- project_type = "universal"
332
- if not src_dir and options.get("path"):
333
- src_dir = options.get("path")
334
- with httpx.Client(
335
- http2=True, base_url=cdxgen_server, timeout=180
336
- ) as client:
337
- sbom_url = f"{cdxgen_server}/sbom"
338
- LOG.debug("Invoking cdxgen server at %s", sbom_url)
339
- try:
340
- r = client.post(
341
- sbom_url,
342
- json={
343
- "url": options.get("url", ""),
344
- "path": options.get("path", src_dir),
345
- "type": options.get("type", project_type),
346
- "multiProject": options.get("multiProject", ""),
347
- },
348
- headers=headers,
349
- )
350
- if r.status_code == httpx.codes.OK:
351
- try:
352
- json_response = r.json()
353
- if json_response:
354
- with open(
355
- bom_file, mode="w", encoding="utf-8"
356
- ) as fp:
357
- json.dump(json_response, fp)
358
- return os.path.exists(bom_file)
359
- except Exception as je:
360
- LOG.error(je)
361
- LOG.info(
362
- "Unable to generate SBOM with cdxgen server. "
363
- "Trying to generate one locally."
364
- )
365
- else:
366
- LOG.warning(
367
- "Unable to generate SBOM via cdxgen server due to %s",
368
- r.status_code,
369
- )
370
- except Exception as e:
371
- LOG.error(e)
313
+ if options is None:
314
+ options = {}
315
+ reachability_analyzer = options.get("reachability_analyzer")
316
+ # The side effect is that we will almost always run blint in deep mode
317
+ if reachability_analyzer != "off" and not options.get("deep"):
318
+ options["deep"] = True
319
+ blint_lib = BlintGenerator(src_dir, bom_file, logger=LOG, options=options)
320
+ with console.status(
321
+ f"Generating BOM for the source '{src_dir}' with blint.", spinner=SPINNER
322
+ ):
323
+ bom_result = blint_lib.generate()
324
+ if not bom_result.success:
325
+ LOG.info(
326
+ "The blint invocation was unsuccessful. Try generating the BOM separately."
327
+ )
328
+ return bom_result.success and os.path.exists(bom_file)
329
+
330
+
331
+ def create_lifecycle_boms(cdxgen_lib, src_dir, options):
332
+ """
333
+ Method to create multiple BOM files for each lifecycle
334
+
335
+ :param cdxgen_lib: cdxgen library to use
336
+ :param src_dir: Source directory
337
+ :param options: Additional options for generating the BOM files
338
+ """
339
+ lifecycles = options.get("lifecycles", []) or []
340
+ if lifecycles:
341
+ LOG.warning(
342
+ "Ignoring the `lifecycles` argument, as it is not required for lifecycle analysis."
343
+ )
344
+ any_success = False
345
+ prebuild_bom_file = options.get("prebuild_bom_file")
346
+ build_bom_file = options.get("build_bom_file")
347
+ postbuild_bom_file = options.get("postbuild_bom_file")
348
+ container_bom_file = options.get("container_bom_file")
349
+ reachability_analyzer = options.get("reachability_analyzer")
350
+ with console.status(
351
+ f"Generating lifecycle-specific BOMs for {src_dir}.", spinner=SPINNER
352
+ ) as status:
353
+ # Start with build BOM generation.
354
+ # This would help atom compute reachable slices from a build perspective without getting confused
355
+ # about the pre-build state.
356
+ status.update(f"Generating build BOM for '{src_dir}' with cdxgen.")
357
+ coptions = {**options, "deep": "true", "lifecycles": ["build"]}
358
+ # We must also run it under research profile to help the reachability analyzer
359
+ # This logic could get refactored in the future
360
+ if reachability_analyzer != "off" and options.get("profile") != "research":
361
+ coptions["profile"] = "research"
362
+ bom_result = cdxgen_lib(
363
+ src_dir, build_bom_file, logger=LOG, options=coptions
364
+ ).generate()
365
+ if not bom_result.success or not os.path.exists(build_bom_file):
366
+ LOG.debug(
367
+ "The cdxgen invocation was unsuccessful. Trying pre-build lifecycle."
368
+ )
369
+ LOG.debug(bom_result.command_output)
370
+ else:
371
+ any_success = True
372
+ # pre-build
373
+ status.update(f"Now generating pre-build BOM for '{src_dir}' with cdxgen.")
374
+ coptions = {**options, "deep": "false", "lifecycles": ["pre-build"]}
375
+ bom_result = cdxgen_lib(
376
+ src_dir, prebuild_bom_file, logger=LOG, options=coptions
377
+ ).generate()
378
+ if not bom_result.success or not os.path.exists(prebuild_bom_file):
379
+ LOG.debug(
380
+ "The cdxgen invocation was unsuccessful. Trying the build lifecycle."
381
+ )
382
+ LOG.debug(bom_result.command_output)
383
+ else:
384
+ any_success = True
385
+ # container bom. For this we need the image name.
386
+ container_image_name = os.getenv("DEPSCAN_SOURCE_IMAGE") or options.get(
387
+ "source_image"
388
+ )
389
+ if container_image_name:
390
+ status.update(f"Generating container BOM for '{src_dir}' with cdxgen.")
391
+ coptions = {**options, "deep": "true", "project_type": ["oci"]}
392
+ if container_image_name == src_dir:
372
393
  LOG.info(
373
- "Unable to generate SBOM with cdxgen server. Trying to "
374
- "generate one locally."
394
+ "Set the environment variable DEPSCAN_SOURCE_IMAGE to the name of the container image to include its components."
375
395
  )
376
- cdxgen_cmd = exec_cdxgen()
377
- if not cdxgen_cmd:
378
- cdxgen_cmd = exec_cdxgen(False)
379
- if project_type in ("docker",):
380
- LOG.info(
381
- "Generating Software Bill-of-Materials for container image %s. "
382
- "This might take a few mins ...",
383
- src_dir,
384
- )
385
- args = [cdxgen_cmd, "-r", "-t", project_type, "-o", bom_file]
386
- if deep or project_type in ("jar", "jenkins") or options.get("reachables"):
387
- args.append("--deep")
388
- LOG.info("About to perform deep scan. This could take a while ...")
389
- if options.get("profile"):
390
- args.append("--profile")
391
- args.append(options.get("profile"))
392
- if options.get("profile") != "generic":
393
- LOG.debug("BOM Profile: %s", options.get("profile"))
394
- if options.get("cdxgen_args"):
395
- args += shlex.split(options.get("cdxgen_args"))
396
- # Bug #233 - Source directory could be None when working with url
397
- if src_dir:
398
- args.append(src_dir)
399
- if cdxgen_cmd:
400
- exec_tool(
401
- args,
402
- src_dir
403
- if project_type not in ("docker", "oci", "container")
404
- and src_dir
405
- and os.path.isdir(src_dir)
406
- else None,
396
+ bom_result = cdxgen_lib(
397
+ container_image_name, container_bom_file, logger=LOG, options=coptions
398
+ ).generate()
399
+ if not bom_result.success or not os.path.exists(container_bom_file):
400
+ LOG.debug(
401
+ "The cdxgen invocation was unsuccessful. Trying for the next lifecycle."
402
+ )
403
+ LOG.debug(bom_result.command_output)
404
+ else:
405
+ any_success = True
406
+ else:
407
+ LOG.debug(
408
+ "Set the environment variable DEPSCAN_SOURCE_IMAGE to the name of the container image to include its components."
409
+ )
410
+ status.update("Preparing blint for post-build BOM generation.")
411
+ # post-build BOM with blint
412
+ coptions = {
413
+ **options,
414
+ "deep": False,
415
+ "use_blintdb": False,
416
+ "lifecycles": ["post-build"],
417
+ }
418
+ # What if the build directory is different to the source
419
+ build_dir = os.getenv("DEPSCAN_BUILD_DIR") or options.get("build_dir") or src_dir
420
+ res = create_blint_bom(postbuild_bom_file, build_dir, options=coptions)
421
+ if not res or not os.path.exists(postbuild_bom_file):
422
+ LOG.debug(
423
+ "The blint invocation was unsuccessful. Try building this project prior to invoking depscan. Alternatively, check if this project generates binary artefacts."
407
424
  )
408
425
  else:
409
- LOG.warning("Unable to locate cdxgen command. ")
410
- return os.path.exists(bom_file)
426
+ any_success = True
427
+ return any_success
428
+
411
429
 
430
+ def create_empty_vdr(pkg_list, ds_version):
431
+ components = pkg_list or []
432
+ bom_data = update_tools_metadata(None, None, ds_version)
433
+ return {**bom_data, "components": components}
412
434
 
413
- def submit_bom(reports_dir, threatdb_params):
435
+
436
+ def update_tools_metadata(tools, bom_data, ds_version):
437
+ """
438
+ Helper function to add depscan information as metadata
439
+ :param tools: Tools section of the SBOM
440
+ :param bom_data: SBOM data
441
+ :param ds_version: depscan version
442
+ :return: None
414
443
  """
415
- Method to submit the SBOM to threatdb for analysis
444
+ if not bom_data:
445
+ now_utc = datetime.now(timezone.utc)
446
+ bom_data = {
447
+ "bomFormat": "CycloneDX",
448
+ "specVersion": "1.6",
449
+ "serialNumber": f"urn:uuid:{uuid.uuid4()}",
450
+ "version": 1,
451
+ "metadata": {
452
+ "timestamp": now_utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
453
+ },
454
+ }
455
+ components = tools.get("components", []) if tools else []
456
+ needs_ds_component = (
457
+ len([c for c in components if c.get("name") == "owasp-depscan"]) == 0
458
+ )
459
+ if needs_ds_component:
460
+ ds_purl = f"pkg:pypi/owasp-depscan@{ds_version}"
461
+ components.append(
462
+ {
463
+ "type": "application",
464
+ "name": "owasp-depscan",
465
+ "version": ds_version,
466
+ "purl": ds_purl,
467
+ "bom-ref": ds_purl,
468
+ }
469
+ )
470
+ bom_data["metadata"]["tools"] = {"components": components}
471
+ return bom_data
472
+
416
473
 
417
- :param reports_dir: The directory where the SBOM reports are located.
418
- :param threatdb_params: A dict of threatdb parameters
474
+ def export_bom(bom_data, ds_version, pkg_vulnerabilities, vdr_file):
419
475
  """
420
- vdr_files = find_files(reports_dir, ".vdr.json")
421
- if vdr_files:
422
- threatdb_server = threatdb_params["threatdb_server"]
423
- if not threatdb_server.endswith("/import"):
424
- threatdb_server = f"{threatdb_server}/import"
425
- login_url = threatdb_server.replace("/import", "/login")
426
- with httpx.Client(
427
- http2=True, base_url=threatdb_server, timeout=180
428
- ) as client:
429
- token = threatdb_params.get("threatdb_token")
430
- if not token:
431
- LOG.debug(
432
- "Attempting to retrieve access token from %s", login_url
433
- )
434
- r = client.post(
435
- login_url,
436
- json={
437
- "username": threatdb_params["threatdb_username"],
438
- "password": threatdb_params["threatdb_password"],
439
- },
440
- headers=headers,
441
- )
442
- if r.status_code == httpx.codes.OK:
443
- json_response = r.json()
444
- if json_response and json_response.get("access_token"):
445
- token = json_response.get("access_token")
446
- else:
447
- LOG.warning(
448
- "Unable to retrieve access token from %s due to %s",
449
- login_url,
450
- r.status_code,
451
- )
452
- if token:
453
- for vf in vdr_files:
454
- files = {"file": open(vf, "rb")}
455
- r = client.post(
456
- threatdb_server,
457
- files=files,
458
- headers={"Authorization": f"Bearer {token}"},
459
- )
460
- if r.status_code == httpx.codes.OK:
461
- json_response = r.json()
462
- if not json_response.get("success"):
463
- LOG.debug(
464
- "Uploaded file %s was not processed successfully",
465
- vf,
466
- )
467
- else:
468
- LOG.debug(
469
- "VDR file %s was submitted successfully to "
470
- "the threatdb server",
471
- vf,
472
- )
473
- else:
474
- LOG.warning(
475
- "Unable to submit VDR file to %s due to %s",
476
- threatdb_server,
477
- r.status_code,
478
- )
476
+ Exports the Bill of Materials (BOM) data along with package vulnerabilities
477
+ to a Vulnerability Data Report (VDR) file.
478
+
479
+ :param bom_data: SBOM data
480
+ :param ds_version: depscan version
481
+ :param pkg_vulnerabilities: Package vulnerabilities
482
+ :param vdr_file: VDR file path
483
+ """
484
+ # Add depscan information as metadata
485
+ metadata = bom_data.get("metadata", {})
486
+ tools = metadata.get("tools", {})
487
+ bom_version = str(bom_data.get("version", 0))
488
+ # Update the version
489
+ if bom_version.isdigit():
490
+ bom_data["version"] = int(bom_version) + 1
491
+ # Update the tools section
492
+ if isinstance(tools, dict):
493
+ bom_data = update_tools_metadata(tools, bom_data, ds_version)
494
+ bom_data = trim_vdr_bom_data(bom_data)
495
+ bom_data["vulnerabilities"] = pkg_vulnerabilities
496
+ json_dump(
497
+ vdr_file,
498
+ bom_data,
499
+ compact=True,
500
+ error_msg=f"Unable to generate VDR file at {vdr_file}",
501
+ )
502
+
503
+
504
+ def trim_vdr_bom_data(bom_data):
505
+ components = bom_data.get("components")
506
+ if not components:
507
+ return bom_data
508
+ metadata = bom_data.get("metadata")
509
+ if metadata and metadata.get("properties"):
510
+ del metadata["properties"]
511
+ bom_data["metadata"] = metadata
512
+ new_components = {}
513
+ component_identities = defaultdict(list)
514
+ for comp in components:
515
+ identity_evidences = comp.get("evidence", {}).get("identity", []) or []
516
+ if isinstance(identity_evidences, dict):
517
+ identity_evidences = [identity_evidences]
518
+ for p in (
519
+ "properties",
520
+ "signature",
521
+ "url",
522
+ "vendor",
523
+ "licenses", # We need a better logic to retain licenses here
524
+ ):
525
+ if comp.get(p) is not None:
526
+ del comp[p]
527
+ ref = comp.get("bom-ref") or comp.get("purl")
528
+ # This is an error condition really
529
+ if not ref:
530
+ continue
531
+ component_identities[ref] += identity_evidences
532
+ if not new_components.get(ref):
533
+ new_components[ref] = comp
534
+ vdr_components = []
535
+ for ref, comp in new_components.items():
536
+ identity_evidences = component_identities[ref]
537
+ comp["evidence"] = {"identity": identity_evidences}
538
+ vdr_components.append(comp)
539
+ bom_data["components"] = vdr_components
540
+ for p in (
541
+ "annotations",
542
+ "signature",
543
+ ):
544
+ if bom_data.get(p):
545
+ del bom_data[p]
546
+ return bom_data
547
+
548
+
549
+ def annotate_vdr(vdr_file, txt_report_file):
550
+ if (
551
+ not vdr_file
552
+ or not txt_report_file
553
+ or not os.path.exists(vdr_file)
554
+ or not os.path.exists(txt_report_file)
555
+ ):
556
+ return
557
+ vdr = json_load(vdr_file)
558
+ metadata = vdr.get("metadata", {})
559
+ tools = metadata.get("tools", {}).get("components", {})
560
+ with open(txt_report_file, errors="ignore", encoding="utf-8") as txt_fp:
561
+ report = txt_fp.read()
562
+ annotations = vdr.get("annotations", []) or []
563
+ depscan_annotation = {
564
+ "subjects": [vdr.get("serialNumber")],
565
+ "annotator": {"component": tools[-1] if len(tools) > 0 else {}},
566
+ "timestamp": metadata.get("timestamp"),
567
+ "text": report,
568
+ }
569
+ annotations.append(depscan_annotation)
570
+ vdr["annotations"] = annotations
571
+ json_dump(
572
+ vdr_file,
573
+ vdr,
574
+ compact=True,
575
+ error_msg=f"Unable to add annotations to the VDR file at {vdr_file}",
576
+ )