owasp-depscan 5.5.0__py3-none-any.whl → 6.0.0a2__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.

Potentially problematic release.


This version of owasp-depscan might be problematic. Click here for more details.

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 +390 -288
  6. depscan/lib/config.py +86 -337
  7. depscan/lib/explainer.py +363 -98
  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.0a2.dist-info/METADATA +390 -0
  19. {owasp_depscan-5.5.0.dist-info → owasp_depscan-6.0.0a2.dist-info}/RECORD +28 -25
  20. {owasp_depscan-5.5.0.dist-info → owasp_depscan-6.0.0a2.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.0a2.dist-info}/entry_points.txt +0 -0
  33. {owasp_depscan-5.5.0.dist-info → owasp_depscan-6.0.0a2.dist-info/licenses}/LICENSE +0 -0
  34. {owasp_depscan-5.5.0.dist-info → owasp_depscan-6.0.0a2.dist-info}/top_level.txt +0 -0
depscan/lib/bom.py CHANGED
@@ -1,46 +1,24 @@
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 blint.cyclonedx.spec import CycloneDX
10
+ from custom_json_diff.lib.utils import json_load, json_dump
10
11
  from defusedxml.ElementTree import parse
12
+ from xbom_lib.blint import BlintGenerator
13
+ from xbom_lib.cdxgen import (
14
+ CdxgenGenerator,
15
+ CdxgenImageBasedGenerator,
16
+ CdxgenServerGenerator,
17
+ )
11
18
 
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)
19
+ from depscan.lib.logger import LOG, SPINNER, console
20
+ from depscan.lib.utils import cleanup_license_string
21
+ from typing import Dict, Optional
44
22
 
45
23
 
46
24
  def parse_bom_ref(bomstr, licenses=None):
@@ -95,15 +73,13 @@ def get_licenses(ele):
95
73
  """
96
74
  license_list = []
97
75
  namespace = "{http://cyclonedx.org/schema/bom/1.5}"
98
- for data in ele.findall(
99
- f"{namespace}licenses/{namespace}license/{namespace}id"
100
- ):
76
+ for data in ele.findall(f"{namespace}licenses/{namespace}license/{namespace}id"):
101
77
  license_list.append(data.text)
102
78
  if not license_list:
103
79
  for data in ele.findall(
104
80
  f"{namespace}licenses/{namespace}license/{namespace}name"
105
81
  ):
106
- if data and data.text:
82
+ if data is not None and data.text:
107
83
  ld_list = [data.text]
108
84
  if "http" in data.text:
109
85
  ld_list = [
@@ -161,40 +137,49 @@ def get_pkg_list_json(jsonfile):
161
137
  return List of dicts representing extracted packages
162
138
  """
163
139
  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
140
+ if bom_data := json_load(jsonfile, log=LOG):
141
+ if bom_data.get("components"):
142
+ for comp in bom_data.get("components", []):
143
+ licenses, vendor, url = get_license_vendor_url(comp)
144
+ pkgs.append(
145
+ {**comp, "vendor": vendor, "licenses": licenses, "url": url}
146
+ )
195
147
  return pkgs
196
148
 
197
149
 
150
+ def get_license_vendor_url(comp):
151
+ licenses = []
152
+ vendor = comp.get("group") or ""
153
+ if comp.get("licenses"):
154
+ for lic in comp.get("licenses"):
155
+ license_obj = lic
156
+ if lic.get("license"):
157
+ license_obj = lic.get("license")
158
+ if license_obj.get("id"):
159
+ licenses.append(license_obj.get("id"))
160
+ elif license_obj.get("name"):
161
+ licenses.append(cleanup_license_string(license_obj.get("name")))
162
+ url = ""
163
+ for aref in comp.get("externalReferences", []):
164
+ if aref.get("type") in (
165
+ "vcs",
166
+ "issue-tracker",
167
+ "website",
168
+ "bom",
169
+ "source-distribution",
170
+ "distribution",
171
+ "distribution-intake",
172
+ "build-system",
173
+ "model-card",
174
+ "evidence",
175
+ "formulation",
176
+ ):
177
+ url = aref.get("url", "")
178
+ break
179
+ return licenses, vendor, url
180
+
181
+
182
+ # Unused
198
183
  def get_pkg_list(xmlfile):
199
184
  """Method to parse the bom xml file and convert into packages list
200
185
 
@@ -232,247 +217,364 @@ def get_pkg_by_type(pkg_list, pkg_type):
232
217
  if not pkg_list:
233
218
  return []
234
219
  return [
235
- pkg
236
- for pkg in pkg_list
237
- if pkg.get("purl", "").startswith("pkg:" + pkg_type)
220
+ pkg for pkg in pkg_list if pkg.get("purl", "").startswith("pkg:" + pkg_type)
238
221
  ]
239
222
 
240
223
 
241
- def resource_path(relative_path):
224
+ def create_bom(bom_file, src_dir=".", options=None):
242
225
  """
243
- Determine the absolute path of a resource file based on its relative path.
226
+ Method to create BOM file by executing cdxgen command
244
227
 
245
- :param relative_path: Relative path of the resource file.
246
- :return: Absolute path of the resource file
228
+ :param bom_file: BOM file
229
+ :param src_dir: Source directory
230
+ :param options: Additional options for generating the BOM file.
231
+ :returns: True if the command was executed. False if the executable was
232
+ not found.
247
233
  """
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
- )
234
+ if not options:
235
+ options = {}
236
+ # Get the various options and filenames
237
+ techniques = options.get("techniques") or []
238
+ lifecycles = options.get("lifecycles") or []
239
+ project_type_list = options.get("project_type") or []
240
+ bom_engine = options.get("bom_engine", "")
241
+ lifecycle_analysis_mode = options.get("lifecycle_analysis_mode", False)
242
+ # Detect if blint needs to be used for the given project type, technique, and lifecycle.
243
+ # For binaries, generate an sbom with blint directly
244
+ if (
245
+ bom_engine == "BlintGenerator"
246
+ or "binary-analysis" in techniques
247
+ or "post-build" in lifecycles
248
+ or any([t in ("binary", "apk") for t in project_type_list])
249
+ ):
250
+ return create_blint_bom(bom_file, src_dir, options=options)
251
+ cdxgen_server = options.get("cdxgen_server")
252
+ cdxgen_lib = CdxgenGenerator
253
+
254
+ # Should we call cdxgen server
255
+ if cdxgen_server or bom_engine == "CdxgenServerGenerator":
256
+ if not cdxgen_server:
257
+ LOG.error(
258
+ "Pass the `--cdxgen-server` argument to use the cdxgen server for BOM generation. Alternatively, use `--bom-engine auto` or `--bom-engine CdxgenGenerator`."
264
259
  )
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
260
+ return False
261
+ cdxgen_lib = CdxgenServerGenerator
281
262
  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",
263
+ # Prefer the new image based generators if docker command is available in auto mode
264
+ if bom_engine == "CdxgenImageBasedGenerator":
265
+ cdxgen_lib = CdxgenImageBasedGenerator
266
+ elif bom_engine == "auto":
267
+ # Prefer local CLI while scanning container images
268
+ if any(
269
+ [
270
+ t in ("docker", "podman", "oci", "os", "hardware")
271
+ for t in project_type_list
272
+ ]
273
+ ):
274
+ cdxgen_lib = CdxgenGenerator
275
+ if lifecycle_analysis_mode:
276
+ LOG.warning(
277
+ "Lifecycle analysis is not supported for oci and os project types."
306
278
  )
307
- )
279
+ lifecycle_analysis_mode = True
280
+ elif (
281
+ shutil.which(os.getenv("DOCKER_CMD", "docker"))
282
+ and sys.platform != "win32"
283
+ ):
284
+ cdxgen_lib = CdxgenImageBasedGenerator
285
+ # We now have the cdxgen library to use.
286
+ # For lifecycle analysis, we need to generate multiple BOM files
287
+ if lifecycle_analysis_mode:
288
+ return create_lifecycle_boms(cdxgen_lib, src_dir, options)
289
+ # Invoke the cdxgen library directly
290
+ with console.status(
291
+ f"Generating BOM for the source '{src_dir}' with cdxgen.", spinner=SPINNER
292
+ ):
293
+ bom_result = cdxgen_lib(
294
+ src_dir, bom_file, logger=LOG, options=options
295
+ ).generate()
296
+ if not bom_result.success:
297
+ LOG.info(
298
+ "The cdxgen invocation was unsuccessful. Try generating the BOM separately."
308
299
  )
309
- return cdxgen_cmd
310
- except Exception:
311
- return None
300
+ LOG.debug(bom_result.command_output)
301
+ return bom_result.success and os.path.exists(bom_file)
312
302
 
313
303
 
314
- def create_bom(project_type, bom_file, src_dir=".", deep=False, options={}):
304
+ def create_blint_bom(
305
+ bom_file: str, src_dir: str = ".", options: Optional[Dict] = None
306
+ ) -> bool:
315
307
  """
316
- Method to create BOM file by executing cdxgen command
308
+ Method to create BOM file by using blint
317
309
 
318
- :param project_type: Project type
319
310
  :param bom_file: BOM file
320
311
  :param src_dir: Source directory
321
- :param deep: A boolean flag indicating whether to perform a deep scan.
322
312
  :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.
313
+ :returns: True if the bom was generated successfully. False otherwise.
325
314
  """
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)
315
+ if options is None:
316
+ options = {}
317
+ reachability_analyzer = options.get("reachability_analyzer")
318
+ # The side effect is that we will almost always run blint in deep mode
319
+ if reachability_analyzer != "off" and not options.get("deep"):
320
+ options["deep"] = True
321
+ blint_lib = BlintGenerator(src_dir, bom_file, logger=LOG, options=options)
322
+ with console.status(
323
+ f"Generating BOM for the source '{src_dir}' with blint.", spinner=SPINNER
324
+ ):
325
+ bom_result = blint_lib.generate()
326
+ if not bom_result.success:
327
+ LOG.info(
328
+ "The blint invocation was unsuccessful. Try generating the BOM separately."
329
+ )
330
+ # Empty SBOM is fine if there are no binaries in the project.
331
+ elif bom_result.bom_obj and isinstance(bom_result.bom_obj, CycloneDX):
332
+ if (
333
+ not bom_result.bom_obj.components
334
+ and not bom_result.bom_obj.dependencies
335
+ ):
336
+ LOG.debug("Empty SBOM received from blint.")
337
+ return bom_result.success and os.path.exists(bom_file)
338
+
339
+
340
+ def create_lifecycle_boms(cdxgen_lib, src_dir, options):
341
+ """
342
+ Method to create multiple BOM files for each lifecycle
343
+
344
+ :param cdxgen_lib: cdxgen library to use
345
+ :param src_dir: Source directory
346
+ :param options: Additional options for generating the BOM files
347
+ """
348
+ lifecycles = options.get("lifecycles", []) or []
349
+ if lifecycles:
350
+ LOG.warning(
351
+ "Ignoring the `lifecycles` argument, as it is not required for lifecycle analysis."
352
+ )
353
+ any_success = False
354
+ prebuild_bom_file = options.get("prebuild_bom_file")
355
+ build_bom_file = options.get("build_bom_file")
356
+ postbuild_bom_file = options.get("postbuild_bom_file")
357
+ container_bom_file = options.get("container_bom_file")
358
+ reachability_analyzer = options.get("reachability_analyzer")
359
+ with console.status(
360
+ f"Generating lifecycle-specific BOMs for {src_dir}.", spinner=SPINNER
361
+ ) as status:
362
+ # Start with build BOM generation.
363
+ # This would help atom compute reachable slices from a build perspective without getting confused
364
+ # about the pre-build state.
365
+ status.update(f"Generating build BOM for '{src_dir}' with cdxgen.")
366
+ coptions = {**options, "deep": "true", "lifecycles": ["build"]}
367
+ # We must also run it under research profile to help the reachability analyzer
368
+ # This logic could get refactored in the future
369
+ if reachability_analyzer != "off" and options.get("profile") != "research":
370
+ coptions["profile"] = "research"
371
+ bom_result = cdxgen_lib(
372
+ src_dir, build_bom_file, logger=LOG, options=coptions
373
+ ).generate()
374
+ if not bom_result.success or not os.path.exists(build_bom_file):
375
+ LOG.debug(
376
+ "The cdxgen invocation was unsuccessful. Trying pre-build lifecycle."
377
+ )
378
+ LOG.debug(bom_result.command_output)
379
+ else:
380
+ any_success = True
381
+ # pre-build
382
+ status.update(f"Now generating pre-build BOM for '{src_dir}' with cdxgen.")
383
+ coptions = {**options, "deep": "false", "lifecycles": ["pre-build"]}
384
+ bom_result = cdxgen_lib(
385
+ src_dir, prebuild_bom_file, logger=LOG, options=coptions
386
+ ).generate()
387
+ if not bom_result.success or not os.path.exists(prebuild_bom_file):
388
+ LOG.debug(
389
+ "The cdxgen invocation was unsuccessful. Trying the build lifecycle."
390
+ )
391
+ LOG.debug(bom_result.command_output)
392
+ else:
393
+ any_success = True
394
+ # container bom. For this we need the image name.
395
+ container_image_name = os.getenv("DEPSCAN_SOURCE_IMAGE") or options.get(
396
+ "source_image"
397
+ )
398
+ if container_image_name:
399
+ status.update(f"Generating container BOM for '{src_dir}' with cdxgen.")
400
+ coptions = {**options, "deep": "true", "project_type": ["oci"]}
401
+ if container_image_name == src_dir:
372
402
  LOG.info(
373
- "Unable to generate SBOM with cdxgen server. Trying to "
374
- "generate one locally."
403
+ "Set the environment variable DEPSCAN_SOURCE_IMAGE to the name of the container image to include its components."
375
404
  )
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,
405
+ bom_result = cdxgen_lib(
406
+ container_image_name, container_bom_file, logger=LOG, options=coptions
407
+ ).generate()
408
+ if not bom_result.success or not os.path.exists(container_bom_file):
409
+ LOG.debug(
410
+ "The cdxgen invocation was unsuccessful. Trying for the next lifecycle."
411
+ )
412
+ LOG.debug(bom_result.command_output)
413
+ else:
414
+ any_success = True
415
+ else:
416
+ LOG.debug(
417
+ "Set the environment variable DEPSCAN_SOURCE_IMAGE to the name of the container image to include its components."
418
+ )
419
+ status.update("Preparing blint for post-build BOM generation.")
420
+ # post-build BOM with blint
421
+ coptions = {**options, "deep": False, "use_blintdb": False, "lifecycles": ["post-build"]}
422
+ # What if the build directory is different to the source
423
+ build_dir = os.getenv("DEPSCAN_BUILD_DIR") or options.get("build_dir") or src_dir
424
+ res = create_blint_bom(postbuild_bom_file, build_dir, options=coptions)
425
+ if not res or not os.path.exists(postbuild_bom_file):
426
+ LOG.debug(
427
+ "The blint invocation was unsuccessful. Try building this project prior to invoking depscan. Alternatively, check if this project generates binary artefacts."
407
428
  )
408
429
  else:
409
- LOG.warning("Unable to locate cdxgen command. ")
410
- return os.path.exists(bom_file)
430
+ any_success = True
431
+ return any_success
432
+
433
+
434
+ def create_empty_vdr(pkg_list, ds_version):
435
+ components = pkg_list or []
436
+ bom_data = update_tools_metadata(None, None, ds_version)
437
+ return {**bom_data, "components": components}
411
438
 
412
439
 
413
- def submit_bom(reports_dir, threatdb_params):
440
+ def update_tools_metadata(tools, bom_data, ds_version):
414
441
  """
415
- Method to submit the SBOM to threatdb for analysis
442
+ Helper function to add depscan information as metadata
443
+ :param tools: Tools section of the SBOM
444
+ :param bom_data: SBOM data
445
+ :param ds_version: depscan version
446
+ :return: None
447
+ """
448
+ if not bom_data:
449
+ now_utc = datetime.now(timezone.utc)
450
+ bom_data = {
451
+ "bomFormat": "CycloneDX",
452
+ "specVersion": "1.6",
453
+ "serialNumber": f"urn:uuid:{uuid.uuid4()}",
454
+ "version": 1,
455
+ "metadata": {
456
+ "timestamp": now_utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
457
+ },
458
+ }
459
+ components = tools.get("components", []) if tools else []
460
+ needs_ds_component = (
461
+ len([c for c in components if c.get("name") == "owasp-depscan"]) == 0
462
+ )
463
+ if needs_ds_component:
464
+ ds_purl = f"pkg:pypi/owasp-depscan@{ds_version}"
465
+ components.append(
466
+ {
467
+ "type": "application",
468
+ "name": "owasp-depscan",
469
+ "version": ds_version,
470
+ "purl": ds_purl,
471
+ "bom-ref": ds_purl,
472
+ }
473
+ )
474
+ bom_data["metadata"]["tools"] = {"components": components}
475
+ return bom_data
416
476
 
417
- :param reports_dir: The directory where the SBOM reports are located.
418
- :param threatdb_params: A dict of threatdb parameters
477
+
478
+ def export_bom(bom_data, ds_version, pkg_vulnerabilities, vdr_file):
419
479
  """
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
- )
480
+ Exports the Bill of Materials (BOM) data along with package vulnerabilities
481
+ to a Vulnerability Data Report (VDR) file.
482
+
483
+ :param bom_data: SBOM data
484
+ :param ds_version: depscan version
485
+ :param pkg_vulnerabilities: Package vulnerabilities
486
+ :param vdr_file: VDR file path
487
+ """
488
+ # Add depscan information as metadata
489
+ metadata = bom_data.get("metadata", {})
490
+ tools = metadata.get("tools", {})
491
+ bom_version = str(bom_data.get("version", 0))
492
+ # Update the version
493
+ if bom_version.isdigit():
494
+ bom_data["version"] = int(bom_version) + 1
495
+ # Update the tools section
496
+ if isinstance(tools, dict):
497
+ bom_data = update_tools_metadata(tools, bom_data, ds_version)
498
+ bom_data = trim_vdr_bom_data(bom_data)
499
+ bom_data["vulnerabilities"] = pkg_vulnerabilities
500
+ json_dump(
501
+ vdr_file,
502
+ bom_data,
503
+ compact=True,
504
+ error_msg=f"Unable to generate VDR file at {vdr_file}",
505
+ )
506
+
507
+
508
+ def trim_vdr_bom_data(bom_data):
509
+ components = bom_data.get("components")
510
+ if not components:
511
+ return bom_data
512
+ metadata = bom_data.get("metadata")
513
+ if metadata and metadata.get("properties"):
514
+ del metadata["properties"]
515
+ bom_data["metadata"] = metadata
516
+ new_components = {}
517
+ component_identities = defaultdict(list)
518
+ for comp in components:
519
+ identity_evidences = comp.get("evidence", {}).get("identity", []) or []
520
+ if isinstance(identity_evidences, dict):
521
+ identity_evidences = [identity_evidences]
522
+ for p in (
523
+ "properties",
524
+ "signature",
525
+ "url",
526
+ "vendor",
527
+ "licenses", # We need a better logic to retain licenses here
528
+ ):
529
+ if comp.get(p) is not None:
530
+ del comp[p]
531
+ ref = comp.get("bom-ref") or comp.get("purl")
532
+ # This is an error condition really
533
+ if not ref:
534
+ continue
535
+ component_identities[ref] += identity_evidences
536
+ if not new_components.get(ref):
537
+ new_components[ref] = comp
538
+ vdr_components = []
539
+ for ref, comp in new_components.items():
540
+ identity_evidences = component_identities[ref]
541
+ comp["evidence"] = {"identity": identity_evidences}
542
+ vdr_components.append(comp)
543
+ bom_data["components"] = vdr_components
544
+ for p in (
545
+ "annotations",
546
+ "signature",
547
+ ):
548
+ if bom_data.get(p):
549
+ del bom_data[p]
550
+ return bom_data
551
+
552
+
553
+ def annotate_vdr(vdr_file, txt_report_file):
554
+ if (
555
+ not vdr_file
556
+ or not txt_report_file
557
+ or not os.path.exists(vdr_file)
558
+ or not os.path.exists(txt_report_file)
559
+ ):
560
+ return
561
+ vdr = json_load(vdr_file)
562
+ metadata = vdr.get("metadata", {})
563
+ tools = metadata.get("tools", {}).get("components", {})
564
+ with open(txt_report_file, errors="ignore", encoding="utf-8") as txt_fp:
565
+ report = txt_fp.read()
566
+ annotations = vdr.get("annotations", []) or []
567
+ depscan_annotation = {
568
+ "subjects": [vdr.get("serialNumber")],
569
+ "annotator": {"component": tools[-1] if len(tools) > 0 else {}},
570
+ "timestamp": metadata.get("timestamp"),
571
+ "text": report,
572
+ }
573
+ annotations.append(depscan_annotation)
574
+ vdr["annotations"] = annotations
575
+ json_dump(
576
+ vdr_file,
577
+ vdr,
578
+ compact=True,
579
+ error_msg=f"Unable to add annotations to the VDR file at {vdr_file}",
580
+ )