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/cli.py CHANGED
@@ -1,710 +1,439 @@
1
- #!/usr/bin/env python3
1
+ #!/usr/bin/env python3 -W ignore::DeprecationWarning
2
2
  # -*- coding: utf-8 -*-
3
3
 
4
- import argparse
4
+ import contextlib
5
5
  import json
6
6
  import os
7
7
  import sys
8
8
  import tempfile
9
+ from typing import List
9
10
 
10
- from defusedxml.ElementTree import parse
11
- from quart import Quart, request
11
+ from analysis_lib import (
12
+ ReachabilityAnalysisKV,
13
+ VdrAnalysisKV,
14
+ )
15
+ from analysis_lib.csaf import export_csaf, write_toml
16
+ from analysis_lib.search import get_pkgs_by_scope
17
+ from analysis_lib.utils import (
18
+ get_all_bom_files,
19
+ get_all_pkg_list,
20
+ get_pkg_list,
21
+ licenses_risk_table,
22
+ pkg_risks_table,
23
+ summary_stats,
24
+ )
25
+ from analysis_lib.vdr import VDRAnalyzer
26
+ from analysis_lib.reachability import get_reachability_impl
27
+ from custom_json_diff.lib.utils import file_write, json_load
12
28
  from rich.panel import Panel
13
29
  from rich.terminal_theme import DEFAULT_TERMINAL_THEME, MONOKAI
14
30
  from vdb.lib import config
15
- from vdb.lib import db as db_lib
16
- from vdb.lib.gha import GitHubSource
17
- from vdb.lib.nvd import NvdSource
18
- from vdb.lib.osv import OSVSource
31
+ from vdb.lib import db6 as db_lib
19
32
  from vdb.lib.utils import parse_purl
20
33
 
21
- from depscan.lib import explainer, github, utils
22
- from depscan.lib.analysis import (
23
- PrepareVdrOptions,
24
- analyse_licenses,
25
- analyse_pkg_risks,
26
- find_purl_usages,
27
- jsonl_report,
28
- prepare_vdr,
29
- suggest_version,
30
- summary_stats,
31
- )
34
+ from depscan import get_version
35
+ from depscan.cli_options import build_parser
36
+ from depscan.lib import explainer, utils
32
37
  from depscan.lib.audit import audit, risk_audit, risk_audit_map, type_audit_map
33
38
  from depscan.lib.bom import (
39
+ annotate_vdr,
40
+ create_empty_vdr,
34
41
  create_bom,
42
+ export_bom,
35
43
  get_pkg_by_type,
36
- get_pkg_list,
37
- submit_bom,
38
44
  )
39
45
  from depscan.lib.config import (
46
+ DEPSCAN_DEFAULT_VDR_FILE,
40
47
  UNIVERSAL_SCAN_TYPE,
48
+ VDB_AGE_HOURS,
41
49
  license_data_dir,
50
+ pkg_max_risk_score,
42
51
  spdx_license_list,
52
+ vdb_database_url,
43
53
  )
44
- from depscan.lib.csaf import export_csaf, write_toml
45
54
  from depscan.lib.license import build_license_data, bulk_lookup
46
- from depscan.lib.logger import DEBUG, LOG, console
47
- from depscan.lib.orasclient import download_image
55
+ from depscan.lib.logger import DEBUG, LOG, SPINNER, console, IS_CI
48
56
 
49
- try:
50
- os.environ["PYTHONIOENCODING"] = "utf-8"
51
- except Exception:
52
- pass
57
+ if sys.platform == "win32" and os.environ.get("PYTHONIOENCODING") is None:
58
+ sys.stdin.reconfigure(encoding="utf-8")
59
+ sys.stdout.reconfigure(encoding="utf-8")
60
+ sys.stderr.reconfigure(encoding="utf-8")
53
61
 
54
62
  LOGO = """
55
- ██████╗ ███████╗██████╗ ███████╗ ██████╗ █████╗ ███╗ ██╗
56
- ██╔══██╗██╔════╝██╔══██╗██╔════╝██╔════╝██╔══██╗████╗ ██║
57
- ██║ ██║█████╗ ██████╔╝███████╗██║ ███████║██╔██╗ ██║
58
- ██║ ██║██╔══╝ ██╔═══╝ ╚════██║██║ ██╔══██║██║╚██╗██║
59
- ██████╔╝███████╗██║ ███████║╚██████╗██║ ██║██║ ╚████║
60
- ╚═════╝ ╚══════╝╚═╝ ╚══════╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═══╝
63
+ _| _ ._ _ _ _. ._
64
+ (_| (/_ |_) _> (_ (_| | |
65
+ |
61
66
  """
62
67
 
68
+ QUART_AVAILABLE = False
69
+ try:
70
+ from quart import Quart, request
71
+
72
+ app = Quart(__name__, static_folder=None)
73
+ app.config.from_prefixed_env()
74
+ app.config["PROVIDE_AUTOMATIC_OPTIONS"] = True
75
+ QUART_AVAILABLE = True
76
+ except ImportError:
77
+ pass
78
+
79
+ ORAS_AVAILABLE = False
80
+ try:
81
+ from vdb.lib.orasclient import download_image
63
82
 
64
- app = Quart(__name__)
65
- app.config.from_prefixed_env()
83
+ ORAS_AVAILABLE = True
84
+ except ImportError:
85
+ pass
66
86
 
67
87
 
68
88
  def build_args():
69
89
  """
70
90
  Constructs command line arguments for the depscan tool
71
91
  """
72
- parser = argparse.ArgumentParser(
73
- description="Fully open-source security and license audit for "
74
- "application dependencies and container images based on "
75
- "known vulnerabilities and advisories.",
76
- epilog="Visit https://github.com/owasp-dep-scan/dep-scan to learn more.",
77
- )
78
- parser.add_argument(
79
- "--no-banner",
80
- action="store_true",
81
- default=False,
82
- dest="no_banner",
83
- help="Do not display the logo and donation banner. Please make a donation to OWASP before using this argument.",
84
- )
85
- parser.add_argument(
86
- "--cache",
87
- action="store_true",
88
- default=False,
89
- dest="cache",
90
- help="Cache vulnerability information in platform specific "
91
- "user_data_dir",
92
- )
93
- parser.add_argument(
94
- "--csaf",
95
- action="store_true",
96
- default=False,
97
- dest="csaf",
98
- help="Generate a OASIS CSAF VEX document",
99
- )
100
- parser.add_argument(
101
- "--sync",
102
- action="store_true",
103
- default=False,
104
- dest="sync",
105
- help="Sync to receive the latest vulnerability data. Should have "
106
- "invoked cache first.",
107
- )
108
- parser.add_argument(
109
- "--profile",
110
- default="generic",
111
- choices=(
112
- "appsec",
113
- "research",
114
- "operational",
115
- "threat-modeling",
116
- "license-compliance",
117
- "generic",
118
- ),
119
- dest="profile",
120
- help="Profile to use while generating the BOM.",
121
- )
122
- parser.add_argument(
123
- "--no-suggest",
124
- action="store_false",
125
- default="True",
126
- dest="suggest",
127
- help="Disable suggest mode",
128
- )
129
- parser.add_argument(
130
- "--risk-audit",
131
- action="store_true",
132
- default=os.getenv("ENABLE_OSS_RISK", "") in ("true", "1"),
133
- dest="risk_audit",
134
- help="Perform package risk audit (slow operation). Npm only.",
135
- )
136
- parser.add_argument(
137
- "--cdxgen-args",
138
- default=os.getenv("CDXGEN_ARGS"),
139
- dest="cdxgen_args",
140
- help="Additional arguments to pass to cdxgen",
141
- )
142
- parser.add_argument(
143
- "--private-ns",
144
- dest="private_ns",
145
- default=os.getenv("PKG_PRIVATE_NAMESPACE"),
146
- help="Private namespace to use while performing oss risk audit. "
147
- "Private packages should not be available in public registries "
148
- "by default. Comma separated values accepted.",
149
- )
150
- parser.add_argument(
151
- "-t",
152
- "--type",
153
- dest="project_type",
154
- default=os.getenv("DEPSCAN_PROJECT_TYPE"),
155
- help="Override project type if auto-detection is incorrect",
156
- )
157
- parser.add_argument(
158
- "--bom",
159
- dest="bom",
160
- help="Examine using the given Software Bill-of-Materials (SBOM) file "
161
- "in CycloneDX format. Use cdxgen command to produce one.",
162
- )
163
- parser.add_argument(
164
- "-i",
165
- "--src",
166
- dest="src_dir_image",
167
- help="Source directory or container image or binary file",
168
- )
169
- parser.add_argument(
170
- "-o",
171
- "--report_file",
172
- dest="report_file",
173
- help="DEPRECATED. Use reports directory since multiple files are "
174
- "created. Report filename with directory",
175
- )
176
- parser.add_argument(
177
- "--reports-dir",
178
- default=os.getenv(
179
- "DEPSCAN_REPORTS_DIR", os.path.join(os.getcwd(), "reports")
180
- ),
181
- dest="reports_dir",
182
- help="Reports directory",
183
- )
184
- parser.add_argument(
185
- "--report-template",
186
- dest="report_template",
187
- help="Jinja template file used for rendering a custom report",
188
- )
189
- parser.add_argument(
190
- "--report-name",
191
- default="rendered.report",
192
- dest="report_name",
193
- help="Filename of the custom report written to the --reports-dir",
194
- )
195
- parser.add_argument(
196
- "--no-error",
197
- action="store_true",
198
- default=False,
199
- dest="noerror",
200
- help="UNUSED: Continue on error to prevent build from breaking",
201
- )
202
- parser.add_argument(
203
- "--no-license-scan",
204
- action="store_true",
205
- default=False,
206
- dest="no_license_scan",
207
- help="UNUSED: dep-scan doesn't perform license scanning by default",
208
- )
209
- parser.add_argument(
210
- "--deep",
211
- action="store_true",
212
- default=False,
213
- dest="deep_scan",
214
- help="Perform deep scan by passing this --deep argument to cdxgen. "
215
- "Useful while scanning docker images and OS packages.",
216
- )
217
- parser.add_argument(
218
- "--no-universal",
219
- action="store_true",
220
- default=False,
221
- dest="non_universal_scan",
222
- help="Depscan would attempt to perform a single universal scan "
223
- "instead of individual scans per language type.",
224
- )
225
- parser.add_argument(
226
- "--no-vuln-table",
227
- action="store_true",
228
- default=False,
229
- dest="no_vuln_table",
230
- help="Do not print the table with the full list of vulnerabilities. "
231
- "This can help reduce console output.",
232
- )
233
- parser.add_argument(
234
- "--threatdb-server",
235
- default=os.getenv("THREATDB_SERVER_URL"),
236
- dest="threatdb_server",
237
- help="ThreatDB server url. Eg: https://api.sbom.cx",
238
- )
239
- parser.add_argument(
240
- "--threatdb-username",
241
- default=os.getenv("THREATDB_USERNAME"),
242
- dest="threatdb_username",
243
- help="ThreatDB username",
244
- )
245
- parser.add_argument(
246
- "--threatdb-password",
247
- default=os.getenv("THREATDB_PASSWORD"),
248
- dest="threatdb_password",
249
- help="ThreatDB password",
250
- )
251
- parser.add_argument(
252
- "--threatdb-token",
253
- default=os.getenv("THREATDB_ACCESS_TOKEN"),
254
- dest="threatdb_token",
255
- help="ThreatDB token for token based submission",
256
- )
257
- parser.add_argument(
258
- "--server",
259
- action="store_true",
260
- default=False,
261
- dest="server_mode",
262
- help="Run depscan as a server",
263
- )
264
- parser.add_argument(
265
- "--server-host",
266
- default=os.getenv("DEPSCAN_HOST", "127.0.0.1"),
267
- dest="server_host",
268
- help="depscan server host",
269
- )
270
- parser.add_argument(
271
- "--server-port",
272
- default=os.getenv("DEPSCAN_PORT", "7070"),
273
- dest="server_port",
274
- help="depscan server port",
275
- )
276
- parser.add_argument(
277
- "--cdxgen-server",
278
- default=os.getenv("CDXGEN_SERVER_URL"),
279
- dest="cdxgen_server",
280
- help="cdxgen server url. Eg: http://cdxgen:9090",
281
- )
282
- parser.add_argument(
283
- "--debug",
284
- action="store_true",
285
- default=False,
286
- dest="enable_debug",
287
- help="Run depscan in debug mode.",
288
- )
289
- parser.add_argument(
290
- "--explain",
291
- action="store_true",
292
- default=False,
293
- dest="explain",
294
- help="Makes depscan to explain the various analysis. Useful for creating detailed reports.",
295
- )
296
- parser.add_argument(
297
- "--reachables-slices-file",
298
- dest="reachables_slices_file",
299
- help="Path for the reachables slices file created by atom.",
300
- )
301
- parser.add_argument(
302
- "--purl",
303
- dest="search_purl",
304
- help="Scan a single package url.",
305
- )
306
- parser.add_argument(
307
- "-v",
308
- "--version",
309
- help="Display the version",
310
- action="version",
311
- version="%(prog)s " + utils.get_version(),
312
- )
92
+ parser = build_parser()
313
93
  return parser.parse_args()
314
94
 
315
95
 
316
- def scan(db, project_type, pkg_list, suggest_mode):
317
- """
318
- Method to search packages in our vulnerability database
319
-
320
- :param db: Reference to db
321
- :param project_type: Project Type
322
- :param pkg_list: List of packages
323
- :param suggest_mode: True if package fix version should be normalized across
324
- findings
325
- :returns: A list of package issue objects or dictionaries.
326
- A dictionary mapping package names to their aliases.
327
- A dictionary mapping packages to their suggested fix versions.
328
- A dictionary mapping package URLs to their aliases.
329
- """
330
- if not pkg_list:
331
- LOG.debug("Empty package search attempted!")
332
- else:
333
- LOG.debug("Scanning %d oss dependencies for issues", len(pkg_list))
334
- results, pkg_aliases, purl_aliases = utils.search_pkgs(
335
- db, project_type, pkg_list
336
- )
337
- # pkg_aliases is a dict that can be used to find the original vendor and
338
- # package name This way we consistently use the same names used by the
339
- # caller irrespective of how the result was obtained
340
- sug_version_dict = {}
341
- if suggest_mode:
342
- # From the results identify optimal max version
343
- sug_version_dict = suggest_version(results, pkg_aliases, purl_aliases)
344
- if sug_version_dict:
345
- LOG.debug(
346
- "Adjusting fix version based on the initial suggestion %s",
347
- sug_version_dict,
348
- )
349
- # Recheck packages
350
- sug_pkg_list = []
351
- for k, v in sug_version_dict.items():
352
- if not v:
353
- continue
354
- vendor = ""
355
- version = v
356
- # Key is already a purl
357
- if k.startswith("pkg:"):
358
- try:
359
- purl_obj = parse_purl(k)
360
- vendor = purl_obj.get("namespace")
361
- if not vendor:
362
- vendor = purl_obj.get("type") or ""
363
- name = purl_obj.get("name") or ""
364
- version = purl_obj.get("version") or ""
365
- sug_pkg_list.append(
366
- {
367
- "vendor": vendor,
368
- "name": name,
369
- "version": version,
370
- "purl": k,
371
- }
372
- )
373
- continue
374
- except Exception:
375
- pass
376
- tmp_a = k.split(":")
377
- if len(tmp_a) == 3:
378
- vendor = tmp_a[0]
379
- name = tmp_a[1]
380
- else:
381
- name = tmp_a[0]
382
- # De-alias the vendor and package name
383
- full_pkg = f"{vendor}:{name}:{version}"
384
- full_pkg = pkg_aliases.get(full_pkg, full_pkg)
385
- vendor, name, version = full_pkg.split(":")
386
- sug_pkg_list.append(
387
- {"vendor": vendor, "name": name, "version": version}
388
- )
389
- LOG.debug(
390
- "Re-checking our suggestion to ensure there are no further "
391
- "vulnerabilities"
392
- )
393
- override_results, _, _ = utils.search_pkgs(
394
- db, project_type, sug_pkg_list
395
- )
396
- if override_results:
397
- new_sug_dict = suggest_version(override_results)
398
- LOG.debug("Received override results: %s", new_sug_dict)
399
- for nk, nv in new_sug_dict.items():
400
- sug_version_dict[nk] = nv
401
- return results, pkg_aliases, sug_version_dict, purl_aliases
402
-
403
-
404
- def summarise(
96
+ def vdr_analyze_summarize(
405
97
  project_type,
406
98
  results,
407
- pkg_aliases,
408
- purl_aliases,
409
- sug_version_dict,
99
+ suggest_mode,
410
100
  scoped_pkgs,
411
- report_file,
412
101
  bom_file,
102
+ bom_dir,
103
+ pkg_list,
104
+ reachability_analyzer,
105
+ reachability_options,
413
106
  no_vuln_table=False,
414
- direct_purls=None,
415
- reached_purls=None,
107
+ fuzzy_search=False,
108
+ search_order=None,
416
109
  ):
417
110
  """
418
- Method to summarise the results
419
- :param project_type: Project type
420
- :param results: Scan or audit results
421
- :param pkg_aliases: Package aliases used
422
- :param purl_aliases: Package URL to package name aliase
423
- :param sug_version_dict: Dictionary containing version suggestions
424
- :param scoped_pkgs: Dict containing package scopes
425
- :param report_file: Output report file
426
- :param bom_file: SBOM file
111
+ Method to perform VDR analysis followed by summarization.
112
+ :param project_type: Project type.
113
+ :param results: Scan or audit results.
114
+ :param suggest_mode: Normalize fix versions automatically.
115
+ :param scoped_pkgs: Dict containing package scopes.
116
+ :param bom_file: Single BOM file.
117
+ :param bom_dir: Directory containining bom files.
118
+ :param pkg_list: Direct list of packages when the bom file is empty.
119
+ :param reachability_analyzer: Reachability Analyzer specified.
120
+ :param reachability_options: Reachability Analyzer options.
427
121
  :param no_vuln_table: Boolean to indicate if the results should get printed
428
- to the console
122
+ to the console.
123
+ :param fuzzy_search: Perform fuzzy search.
124
+ :param search_order: Search order.
125
+
429
126
  :return: A dict of vulnerability and severity summary statistics
430
127
  """
431
- if report_file:
432
- jsonl_report(
433
- project_type,
434
- results,
435
- pkg_aliases,
436
- purl_aliases,
437
- sug_version_dict,
438
- scoped_pkgs,
439
- report_file,
440
- direct_purls=direct_purls,
441
- reached_purls=reached_purls,
442
- )
443
- options = PrepareVdrOptions(
128
+ pkg_vulnerabilities = []
129
+ summary = {}
130
+ direct_purls = {}
131
+ reached_purls = {}
132
+ reached_services = {}
133
+ endpoint_reached_purls = {}
134
+ # Perform the reachability analysis first
135
+ reach_result = get_reachability_impl(
136
+ reachability_analyzer, reachability_options
137
+ ).process()
138
+ # We now have reachability results, OpenAPI endpoints, BOMs, and component scope information.
139
+ if reach_result and reach_result.success:
140
+ direct_purls = reach_result.direct_purls
141
+ reached_purls = reach_result.reached_purls
142
+ reached_services = reach_result.reached_services
143
+ endpoint_reached_purls = reach_result.endpoint_reached_purls
144
+ console.record = True
145
+ # We might already have the needed slices files when we reach here.
146
+ options = VdrAnalysisKV(
444
147
  project_type,
445
148
  results,
446
- pkg_aliases,
447
- purl_aliases,
448
- sug_version_dict,
149
+ pkg_aliases={},
150
+ purl_aliases={},
151
+ suggest_mode=suggest_mode,
449
152
  scoped_pkgs=scoped_pkgs,
450
153
  no_vuln_table=no_vuln_table,
451
154
  bom_file=bom_file,
155
+ bom_dir=bom_dir,
156
+ pkg_list=pkg_list,
452
157
  direct_purls=direct_purls,
453
158
  reached_purls=reached_purls,
454
- )
455
- pkg_vulnerabilities, pkg_group_rows = prepare_vdr(options)
456
- vdr_file = bom_file.replace(".json", ".vdr.json") if bom_file else None
457
- if pkg_vulnerabilities and bom_file:
458
- try:
459
- with open(bom_file, encoding="utf-8") as fp:
460
- bom_data = json.load(fp)
461
- if bom_data:
462
- # Add depscan information as metadata
463
- metadata = bom_data.get("metadata", {})
464
- tools = metadata.get("tools", {})
465
- bom_version = str(bom_data.get("version", 1))
466
- # Update the version
467
- if bom_version.isdigit():
468
- bom_version = int(bom_version) + 1
469
- bom_data["version"] = bom_version
470
- # Update the tools section
471
- if isinstance(tools, dict):
472
- components = tools.get("components", [])
473
- ds_version = utils.get_version()
474
- ds_purl = f"pkg:pypi/owasp-depscan@{ds_version}"
475
- components.append(
476
- {
477
- "type": "application",
478
- "name": "owasp-depscan",
479
- "version": ds_version,
480
- "purl": ds_purl,
481
- "bom-ref": ds_purl,
482
- }
483
- )
484
- tools["components"] = components
485
- metadata["tools"] = tools
486
- bom_data["metadata"] = metadata
487
-
488
- bom_data["vulnerabilities"] = pkg_vulnerabilities
489
- with open(vdr_file, mode="w", encoding="utf-8") as vdrfp:
490
- json.dump(bom_data, vdrfp, indent=4)
491
- LOG.debug(
492
- "VDR file %s generated successfully", vdr_file
493
- )
494
- except Exception:
495
- LOG.warning("Unable to generate VDR file for this scan")
496
- summary = summary_stats(results)
497
- return summary, vdr_file, pkg_vulnerabilities, pkg_group_rows
498
-
159
+ reached_services=reached_services,
160
+ endpoint_reached_purls=endpoint_reached_purls,
161
+ console=console,
162
+ logger=LOG,
163
+ fuzzy_search=fuzzy_search,
164
+ search_order=search_order,
165
+ )
166
+ ds_version = get_version()
167
+ vdr_result = VDRAnalyzer(vdr_options=options).process()
168
+ vdr_file = bom_file.replace(".cdx.json", ".vdr.json") if bom_file else None
169
+ if not vdr_file and bom_dir:
170
+ vdr_file = os.path.join(bom_dir, DEPSCAN_DEFAULT_VDR_FILE)
171
+ if vdr_result.success:
172
+ pkg_vulnerabilities = vdr_result.pkg_vulnerabilities
173
+ cdx_vdr_data = None
174
+ # Always create VDR files even when empty
175
+ if pkg_vulnerabilities is not None:
176
+ # Case 1: Single BOM file resulting in a single VDR file
177
+ if bom_file:
178
+ cdx_vdr_data = json_load(bom_file, log=LOG)
179
+ # Case 2: Multiple BOM files in a bom directory
180
+ elif bom_dir:
181
+ cdx_vdr_data = create_empty_vdr(pkg_list, ds_version)
182
+ if cdx_vdr_data:
183
+ export_bom(cdx_vdr_data, ds_version, pkg_vulnerabilities, vdr_file)
184
+ LOG.debug(f"The VDR file '{vdr_file}' was created successfully.")
185
+ else:
186
+ LOG.debug(
187
+ f"VDR file '{vdr_file}' was not created for the type {project_type}."
188
+ )
189
+ summary = summary_stats(pkg_vulnerabilities)
190
+ elif bom_dir or bom_file or pkg_list:
191
+ if project_type != "bom":
192
+ LOG.info("No vulnerabilities found for project type '%s'!", project_type)
193
+ else:
194
+ LOG.info("No vulnerabilities found!")
195
+ return summary, vdr_file, vdr_result
499
196
 
500
- @app.get("/")
501
- async def index():
502
- """
503
197
 
504
- :return: An empty dictionary
198
+ def set_project_types(args, src_dir):
505
199
  """
506
- return {}
200
+ Detects the project types and perform the right type of scan
507
201
 
202
+ :param args: cli arguments
203
+ :param src_dir: source directory
508
204
 
509
- @app.get("/cache")
510
- async def cache():
205
+ :return: A tuple containing the package list, the parsed package URL object,
206
+ and the list of project types.
511
207
  """
208
+ pkg_list, purl_obj = [], {}
209
+ project_types_list: List[str] = []
210
+ if args.search_purl:
211
+ purl_obj = parse_purl(args.search_purl)
212
+ purl_obj["purl"] = args.search_purl
213
+ purl_obj["vendor"] = purl_obj.get("namespace")
214
+ if purl_obj.get("type"):
215
+ project_types_list = [purl_obj.get("type", "")]
216
+ pkg_list = [purl_obj]
217
+ elif args.bom or args.bom_dir:
218
+ project_types_list = ["bom"]
219
+ elif args.project_type:
220
+ project_types_list = (
221
+ args.project_type
222
+ if isinstance(args.project_type, list)
223
+ else args.project_type.split(",")
224
+ )
225
+ if len(project_types_list) == 1 and "," in project_types_list[0]:
226
+ project_types_list = project_types_list[0].split(",")
227
+ elif not args.non_universal_scan:
228
+ project_types_list = [UNIVERSAL_SCAN_TYPE]
229
+ else:
230
+ project_types_list = utils.detect_project_type(src_dir)
231
+ return pkg_list, project_types_list
512
232
 
513
- :return: a JSON response indicating the status of the caching operation.
514
- """
515
- db = db_lib.get()
516
- if not db_lib.index_count(db["index_file"]):
517
- paths_list = download_image()
518
- if paths_list:
519
- return {
520
- "error": "false",
521
- "message": "vulnerability database cached successfully",
522
- }
523
- else:
524
- return {
525
- "error": "true",
526
- "message": "vulnerability database was not cached",
527
- }
528
- return {
529
- "error": "false",
530
- "message": "vulnerability database already exists",
531
- }
532
233
 
234
+ if QUART_AVAILABLE:
533
235
 
534
- @app.route("/scan", methods=["GET", "POST"])
535
- async def run_scan():
536
- """
537
- :return: A JSON response containing the SBOM file path and a list of
538
- vulnerabilities found in the scanned packages
539
- """
540
- q = request.args
541
- params = await request.get_json()
542
- uploaded_bom_file = await request.files
236
+ @app.get("/")
237
+ async def index():
238
+ """
543
239
 
544
- url = None
545
- path = None
546
- multi_project = None
547
- project_type = None
548
- results = []
549
- db = db_lib.get()
550
- profile = "generic"
551
- deep = False
552
- if q.get("url"):
553
- url = q.get("url")
554
- if q.get("path"):
555
- path = q.get("path")
556
- if q.get("multiProject"):
557
- multi_project = q.get("multiProject", "").lower() in ("true", "1")
558
- if q.get("deep"):
559
- deep = q.get("deep", "").lower() in ("true", "1")
560
- if q.get("type"):
561
- project_type = q.get("type")
562
- if q.get("profile"):
563
- profile = q.get("profile")
564
- if params is not None:
565
- if not url and params.get("url"):
566
- url = params.get("url")
567
- if not path and params.get("path"):
568
- path = params.get("path")
569
- if not multi_project and params.get("multiProject"):
570
- multi_project = params.get("multiProject", "").lower() in (
571
- "true",
572
- "1",
573
- )
574
- if not deep and params.get("deep"):
575
- deep = params.get("deep", "").lower() in (
576
- "true",
577
- "1",
578
- )
579
- if not project_type and params.get("type"):
580
- project_type = params.get("type")
581
- if not profile and params.get("profile"):
582
- profile = params.get("profile")
240
+ :return: An empty dictionary
241
+ """
242
+ return {}
583
243
 
584
- if not path and not url and (uploaded_bom_file.get("file", None) is None):
585
- return {
586
- "error": "true",
587
- "message": "path or url or a bom file upload is required",
588
- }, 400
589
- if not project_type:
590
- return {"error": "true", "message": "project type is required"}, 400
244
+ @app.get("/download-vdb")
245
+ async def download_vdb():
246
+ """
591
247
 
592
- if not db_lib.index_count(db["index_file"]):
593
- return (
594
- {
248
+ :return: a JSON response indicating the status of the caching operation.
249
+ """
250
+ if db_lib.needs_update(days=0, hours=VDB_AGE_HOURS, default_status=False):
251
+ if not ORAS_AVAILABLE:
252
+ return {
253
+ "error": "true",
254
+ "message": "The oras package must be installed to automatically download the vulnerability database. Install depscan using `pip install owasp-depscan[all]` or use the official container image.",
255
+ }
256
+ if download_image(vdb_database_url, config.DATA_DIR):
257
+ return {
258
+ "error": "false",
259
+ "message": "vulnerability database downloaded successfully",
260
+ }
261
+ return {
595
262
  "error": "true",
596
- "message": "Vulnerability database is empty. Prepare the "
597
- "vulnerability database by invoking /cache endpoint "
598
- "before running scans.",
599
- },
600
- 500,
601
- {"Content-Type": "application/json"},
602
- )
603
-
604
- cdxgen_server = app.config.get("CDXGEN_SERVER_URL")
605
- bom_file_path = None
263
+ "message": "vulnerability database did not get downloaded correctly. Check the server logs.",
264
+ }
265
+ return {
266
+ "error": "false",
267
+ "message": "vulnerability database already exists",
268
+ }
269
+
270
+ @app.route("/scan", methods=["GET", "POST"])
271
+ async def run_scan():
272
+ """
273
+ :return: A JSON response containing the SBOM file path and a list of
274
+ vulnerabilities found in the scanned packages
275
+ """
276
+ q = request.args
277
+ params = await request.get_json()
278
+ uploaded_bom_file = await request.files
279
+
280
+ url = None
281
+ path = None
282
+ multi_project = None
283
+ project_type = None
284
+ results = []
285
+ profile = "generic"
286
+ deep = False
287
+ suggest_mode = True if q.get("suggest") in ("true", "1") else False
288
+ fuzzy_search = True if q.get("fuzzy_search") in ("true", "1") else False
289
+ if q.get("url"):
290
+ url = q.get("url")
291
+ if q.get("path"):
292
+ path = q.get("path")
293
+ if q.get("multiProject"):
294
+ multi_project = q.get("multiProject", "").lower() in ("true", "1")
295
+ if q.get("deep"):
296
+ deep = q.get("deep", "").lower() in ("true", "1")
297
+ if q.get("type"):
298
+ project_type = q.get("type")
299
+ if q.get("profile"):
300
+ profile = q.get("profile")
301
+ if params is not None:
302
+ if not url and params.get("url"):
303
+ url = params.get("url")
304
+ if not path and params.get("path"):
305
+ path = params.get("path")
306
+ if not multi_project and params.get("multiProject"):
307
+ multi_project = params.get("multiProject", "").lower() in (
308
+ "true",
309
+ "1",
310
+ )
311
+ if not deep and params.get("deep"):
312
+ deep = params.get("deep", "").lower() in (
313
+ "true",
314
+ "1",
315
+ )
316
+ if not project_type and params.get("type"):
317
+ project_type = params.get("type")
318
+ if not profile and params.get("profile"):
319
+ profile = params.get("profile")
606
320
 
607
- if uploaded_bom_file.get("file", None) is not None:
608
- bom_file = uploaded_bom_file["file"]
609
- bom_file_content = bom_file.read().decode("utf-8")
610
- try:
611
- if str(bom_file.filename).endswith(".json"):
612
- _ = json.loads(bom_file_content)
613
- else:
614
- _ = parse(bom_file_content)
615
- except Exception as e:
616
- LOG.info(e)
321
+ if not path and not url and (uploaded_bom_file.get("file", None) is None):
322
+ return {
323
+ "error": "true",
324
+ "message": "path or url or a bom file upload is required",
325
+ }, 400
326
+ if not project_type:
327
+ return {"error": "true", "message": "project type is required"}, 400
328
+ if db_lib.needs_update(days=0, hours=VDB_AGE_HOURS, default_status=False):
617
329
  return (
618
330
  {
619
331
  "error": "true",
620
- "message": "The uploaded file must be a valid JSON or XML.",
332
+ "message": "Vulnerability database is empty. Prepare the "
333
+ "vulnerability database by invoking /download-vdb endpoint "
334
+ "before running scans.",
621
335
  },
622
- 400,
336
+ 500,
623
337
  {"Content-Type": "application/json"},
624
338
  )
625
339
 
626
- LOG.debug("Processing uploaded file")
627
- bom_file_suffix = str(bom_file.filename).rsplit(".", maxsplit=1)[-1]
628
- tmp_bom_file = tempfile.NamedTemporaryFile(
629
- delete=False, suffix=f".bom.{bom_file_suffix}"
630
- )
631
- with open(tmp_bom_file.name, "w", encoding="utf-8") as f:
632
- f.write(bom_file_content)
633
- path = tmp_bom_file.name
634
-
635
- # Path points to a project directory
636
- # Bug# 233. Path could be a url
637
- if url or (path and os.path.isdir(path)):
638
- with tempfile.NamedTemporaryFile(
639
- delete=False, suffix=".bom.json"
640
- ) as bfp:
641
- bom_status = create_bom(
642
- project_type,
643
- bfp.name,
644
- path,
645
- deep,
646
- {
647
- "url": url,
648
- "path": path,
649
- "type": project_type,
650
- "multiProject": multi_project,
651
- "cdxgen_server": cdxgen_server,
652
- "profile": profile,
653
- },
654
- )
655
- if bom_status:
656
- LOG.debug("BOM file was generated successfully at %s", bfp.name)
657
- bom_file_path = bfp.name
340
+ cdxgen_server = app.config.get("CDXGEN_SERVER_URL")
341
+ bom_file_path = None
658
342
 
659
- # Path points to a SBOM file
660
- else:
661
- if os.path.exists(path):
662
- bom_file_path = path
343
+ if uploaded_bom_file.get("file", None) is not None:
344
+ bom_file = uploaded_bom_file["file"]
345
+ bom_file_content = bom_file.read().decode("utf-8")
346
+ try:
347
+ _ = json.loads(bom_file_content)
348
+ except Exception as e:
349
+ LOG.info(e)
350
+ return (
351
+ {
352
+ "error": "true",
353
+ "message": "The uploaded file must be a valid JSON or XML.",
354
+ },
355
+ 400,
356
+ {"Content-Type": "application/json"},
357
+ )
663
358
 
664
- if bom_file_path is not None:
665
- pkg_list = get_pkg_list(bom_file_path)
666
- if not pkg_list:
667
- return {}
668
- if project_type in type_audit_map:
669
- audit_results = audit(project_type, pkg_list)
670
- if audit_results:
671
- results = results + audit_results
672
- vdb_results, pkg_aliases, sug_version_dict, purl_aliases = scan(
673
- db, project_type, pkg_list, True
674
- )
675
- if vdb_results:
676
- results += vdb_results
677
- results = [r.to_dict() for r in results]
678
- bom_data = None
679
- with open(bom_file_path, encoding="utf-8") as fp:
680
- bom_data = json.load(fp)
681
- if not bom_data:
682
- return (
683
- {
684
- "error": "true",
685
- "message": "Unable to generate SBOM. Check your input path or url.",
686
- },
687
- 400,
688
- {"Content-Type": "application/json"},
359
+ LOG.debug("Processing uploaded file")
360
+ bom_file_suffix = str(bom_file.filename).rsplit(".", maxsplit=1)[-1]
361
+ tmp_bom_file = tempfile.NamedTemporaryFile(
362
+ delete=False, suffix=f".bom.{bom_file_suffix}"
689
363
  )
690
- options = PrepareVdrOptions(
691
- project_type,
692
- results,
693
- pkg_aliases,
694
- purl_aliases,
695
- sug_version_dict,
696
- scoped_pkgs={},
697
- no_vuln_table=True,
698
- bom_file=bom_file_path,
699
- direct_purls=None,
700
- reached_purls=None,
701
- )
702
- pkg_vulnerabilities, _ = prepare_vdr(options)
703
- if pkg_vulnerabilities:
704
- bom_data["vulnerabilities"] = pkg_vulnerabilities
705
- return json.dumps(bom_data), 200, {"Content-Type": "application/json"}
364
+ path = tmp_bom_file.name
365
+ file_write(path, bom_file_content)
366
+
367
+ # Path points to a project directory
368
+ # Bug# 233. Path could be a url
369
+ if url or (path and os.path.isdir(path)):
370
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".bom.json") as bfp:
371
+ project_type_list = project_type.split(",")
372
+ bom_status = create_bom(
373
+ bfp.name,
374
+ path,
375
+ {
376
+ "url": url,
377
+ "path": path,
378
+ "project_type": project_type_list,
379
+ "multiProject": multi_project,
380
+ "cdxgen_server": cdxgen_server,
381
+ "profile": profile,
382
+ "deep": deep,
383
+ },
384
+ )
385
+ if bom_status:
386
+ LOG.debug("BOM file was generated successfully at %s", bfp.name)
387
+ bom_file_path = bfp.name
706
388
 
707
- else:
389
+ # Path points to a SBOM file
390
+ else:
391
+ if os.path.exists(path):
392
+ bom_file_path = path
393
+ # Direct purl-based lookups are not supported yet.
394
+ if bom_file_path is not None:
395
+ pkg_list, _ = get_pkg_list(bom_file_path)
396
+ # Here we are assuming there will be only one type
397
+ if project_type in type_audit_map:
398
+ audit_results = audit(project_type, pkg_list)
399
+ if audit_results:
400
+ results = results + audit_results
401
+ if not pkg_list:
402
+ LOG.debug("Empty package search attempted!")
403
+ else:
404
+ LOG.debug("Scanning %d oss dependencies for issues", len(pkg_list))
405
+ bom_data = json_load(bom_file_path)
406
+ if not bom_data:
407
+ return (
408
+ {
409
+ "error": "true",
410
+ "message": "Unable to generate SBOM. Check your input path or url.",
411
+ },
412
+ 400,
413
+ {"Content-Type": "application/json"},
414
+ )
415
+ options = VdrAnalysisKV(
416
+ project_type,
417
+ results,
418
+ pkg_aliases={},
419
+ purl_aliases={},
420
+ suggest_mode=suggest_mode,
421
+ scoped_pkgs={},
422
+ no_vuln_table=True,
423
+ bom_file=bom_file_path,
424
+ pkg_list=[],
425
+ direct_purls={},
426
+ reached_purls={},
427
+ console=console,
428
+ logger=LOG,
429
+ fuzzy_search=fuzzy_search,
430
+ )
431
+ vdr_result = VDRAnalyzer(vdr_options=options).process()
432
+ if vdr_result.success:
433
+ pkg_vulnerabilities = vdr_result.pkg_vulnerabilities
434
+ if pkg_vulnerabilities:
435
+ bom_data["vulnerabilities"] = pkg_vulnerabilities
436
+ return json.dumps(bom_data), 200, {"Content-Type": "application/json"}
708
437
  return (
709
438
  {
710
439
  "error": "true",
@@ -714,48 +443,50 @@ async def run_scan():
714
443
  {"Content-Type": "application/json"},
715
444
  )
716
445
 
446
+ def run_server(args):
447
+ """
448
+ Run depscan as server
717
449
 
718
- def run_server(args):
719
- """
720
- Run depscan as server
721
-
722
- :param args: Command line arguments passed to the function.
723
- """
724
- print(LOGO)
725
- console.print(
726
- f"Depscan server running on {args.server_host}:{args.server_port}"
727
- )
728
- app.config["CDXGEN_SERVER_URL"] = args.cdxgen_server
729
- app.run(
730
- host=args.server_host,
731
- port=args.server_port,
732
- debug=os.getenv("SCAN_DEBUG_MODE") == "debug"
733
- or os.getenv("AT_DEBUG_MODE") == "debug",
734
- use_reloader=False,
735
- )
450
+ :param args: Command line arguments passed to the function.
451
+ """
452
+ print(LOGO)
453
+ console.print(
454
+ f"Depscan server running on {args.server_host}:{args.server_port}"
455
+ )
456
+ app.config["CDXGEN_SERVER_URL"] = args.cdxgen_server
457
+ app.run(
458
+ host=args.server_host,
459
+ port=args.server_port,
460
+ debug=os.getenv("SCAN_DEBUG_MODE") == "debug",
461
+ use_reloader=False,
462
+ )
736
463
 
737
464
 
738
- def main():
465
+ def run_depscan(args):
739
466
  """
740
467
  Detects the project type, performs various scans and audits,
741
468
  and generates reports based on the results.
742
469
  """
743
- args = build_args()
744
470
  perform_risk_audit = args.risk_audit
745
471
  # declare variables that get initialized only conditionally
746
472
  (
747
473
  summary,
748
474
  vdr_file,
749
475
  bom_file,
476
+ prebuild_bom_file,
477
+ build_bom_file,
478
+ postbuild_bom_file,
479
+ container_bom_file,
480
+ operations_bom_file,
750
481
  pkg_list,
751
- pkg_vulnerabilities,
752
- pkg_group_rows,
753
- ) = (None, None, None, None, None, None)
482
+ all_pkg_vulnerabilities,
483
+ all_pkg_group_rows,
484
+ ) = (None, None, None, None, None, None, None, None, None, [], {})
754
485
  if (
755
486
  os.getenv("CI")
487
+ and not os.getenv("GITHUB_REPOSITORY", "").lower().startswith("owasp")
756
488
  and not args.no_banner
757
- and not os.getenv("INPUT_THANK_YOU", "")
758
- == ("I have sponsored OWASP-dep-scan.")
489
+ and not os.getenv("INPUT_THANK_YOU", "") == "I have sponsored OWASP-dep-scan."
759
490
  ):
760
491
  console.print(
761
492
  Panel(
@@ -764,14 +495,49 @@ def main():
764
495
  expand=False,
765
496
  )
766
497
  )
767
- # Should we turn on the debug mode
498
+ # Should we be quiet
499
+ if args.quiet:
500
+ args.explain = False
501
+ LOG.disabled = True
502
+ args.enable_debug = False
503
+ os.environ["SCAN_DEBUG_MODE"] = "off"
504
+ os.environ["CDXGEN_DEBUG_MODE"] = "off"
505
+ console.quiet = True
506
+ args.no_vuln_table = True
507
+ # Should we enable debug
768
508
  if args.enable_debug:
769
- os.environ["AT_DEBUG_MODE"] = "debug"
509
+ os.environ["SCAN_DEBUG_MODE"] = "debug"
510
+ os.environ["CDXGEN_DEBUG_MODE"] = "debug"
770
511
  LOG.setLevel(DEBUG)
771
512
  if args.server_mode:
772
- return run_server(args)
513
+ if QUART_AVAILABLE:
514
+ return run_server(args)
515
+ else:
516
+ LOG.info(
517
+ "The required packages for server mode are unavailable. Reinstall depscan using `pip install owasp-depscan[all]`."
518
+ )
519
+ return False
773
520
  if not args.no_banner:
774
- print(LOGO)
521
+ with contextlib.suppress(UnicodeEncodeError):
522
+ print(LOGO)
523
+ # Break early if the user prefers CPE-based searches
524
+ search_order = args.search_order
525
+ if search_order:
526
+ if search_order.startswith("c") and not args.bom and not args.bom_dir:
527
+ LOG.warning(
528
+ "To perform CPE-based searches, the SBOM must include a CPE identifier for each component. Generate the SBOM using a compatible tool such as Syft or Trivy, and invoke depscan with the --bom or --bom-dir argument."
529
+ )
530
+ LOG.info(
531
+ "Alternatively, run depscan without the `--search-order` argument to perform PURL-based searches. This method is more accurate and recommended."
532
+ )
533
+ sys.exit(1)
534
+ elif search_order.startswith("u") and not os.getenv("FETCH_LICENSE"):
535
+ LOG.warning(
536
+ "To perform URL-based searches, the SBOM must include externalReferences with a URL. Set the environment variable `FETCH_LICENSE=true` to force cdxgen to populate this attribute."
537
+ )
538
+ LOG.info(
539
+ "Alternatively, include the project type `-t license` to ensure this attribute is populated."
540
+ )
775
541
  src_dir = args.src_dir_image
776
542
  if not src_dir or src_dir == ".":
777
543
  if src_dir == "." or args.search_purl:
@@ -779,9 +545,51 @@ def main():
779
545
  # Try to infer from the bom file
780
546
  elif args.bom and os.path.exists(args.bom):
781
547
  src_dir = os.path.dirname(os.path.realpath(args.bom))
548
+ elif args.bom_dir and os.path.exists(args.bom_dir):
549
+ src_dir = os.path.realpath(args.bom_dir)
782
550
  else:
783
551
  src_dir = os.getcwd()
784
552
  reports_dir = args.reports_dir
553
+ # User has not provided an explicit reports_dir. Reuse the bom_dir
554
+ if not reports_dir and args.bom_dir:
555
+ reports_dir = os.path.realpath(args.bom_dir)
556
+ # Are we running for a BOM directory
557
+ bom_dir_mode = args.bom_dir and os.path.exists(args.bom_dir)
558
+ # Are we running with a config file
559
+ config_file_mode = args.config and os.path.exists(args.config)
560
+ depscan_options = {**vars(args)}
561
+ depscan_options["src_dir"] = src_dir
562
+ depscan_options["reports_dir"] = reports_dir
563
+ # Is the user looking for semantic analysis?
564
+ # We can default to this when run against a BOM directory
565
+ if (
566
+ args.reachability_analyzer == "SemanticReachability"
567
+ ) and args.vuln_analyzer != "LifecycleAnalyzer":
568
+ LOG.debug(
569
+ "Automatically switching to the `LifecycleAnalyzer` for vulnerability analysis."
570
+ )
571
+ depscan_options["vuln_analyzer"] = "LifecycleAnalyzer"
572
+ args.vuln_analyzer = "LifecycleAnalyzer"
573
+ # Should we download the latest vdb.
574
+ if db_lib.needs_update(
575
+ days=0,
576
+ hours=VDB_AGE_HOURS,
577
+ default_status=db_lib.get_db_file_metadata is not None,
578
+ ):
579
+ if ORAS_AVAILABLE:
580
+ with console.status(
581
+ f"Downloading the latest vulnerability database to {config.DATA_DIR}. Please wait ...",
582
+ spinner=SPINNER,
583
+ ) as vdb_download_status:
584
+ if not IS_CI:
585
+ vdb_download_status.stop()
586
+ # This line may exit with an exception if the database cannot be downloaded.
587
+ # Example: urllib3.exceptions.IncompleteRead, urllib3.exceptions.ProtocolError, requests.exceptions.ChunkedEncodingError
588
+ download_image(vdb_database_url, config.DATA_DIR)
589
+ else:
590
+ LOG.warning(
591
+ "The latest vulnerability database is not found. Follow the documentation to manually download it."
592
+ )
785
593
  if args.csaf:
786
594
  toml_file_path = os.getenv(
787
595
  "DEPSCAN_CSAF_TEMPLATE", os.path.join(src_dir, "csaf.toml")
@@ -789,9 +597,7 @@ def main():
789
597
  if not os.path.exists(toml_file_path):
790
598
  LOG.info("CSAF toml not found, creating template in %s", src_dir)
791
599
  write_toml(toml_file_path)
792
- LOG.info(
793
- "Please fill out the toml with your details and rerun depscan."
794
- )
600
+ LOG.info("Please fill out the toml with your details and rerun depscan.")
795
601
  LOG.info(
796
602
  "Check out our CSAF documentation for an explanation of "
797
603
  "this feature. https://github.com/owasp-dep-scan/dep-scan"
@@ -803,32 +609,24 @@ def main():
803
609
  "depscan."
804
610
  )
805
611
  sys.exit(0)
806
- # Detect the project types and perform the right type of scan
807
- if args.project_type:
808
- project_types_list = args.project_type.split(",")
809
- elif args.search_purl:
612
+ pkg_list, project_types_list = set_project_types(args, src_dir)
613
+ if args.search_purl:
810
614
  # Automatically enable risk audit for single purl searches
811
615
  perform_risk_audit = True
812
- purl_obj = parse_purl(args.search_purl)
813
- purl_obj["purl"] = args.search_purl
814
- purl_obj["vendor"] = purl_obj.get("namespace")
815
- project_types_list = [purl_obj.get("type")]
816
- pkg_list = [purl_obj]
817
- elif args.bom:
818
- project_types_list = ["bom"]
819
- elif not args.non_universal_scan:
820
- project_types_list = [UNIVERSAL_SCAN_TYPE]
821
- else:
822
- project_types_list = utils.detect_project_type(src_dir)
823
- db = db_lib.get()
824
- run_cacher = args.cache
825
- areport_file = (
826
- args.report_file
827
- if args.report_file
828
- else os.path.join(reports_dir, "depscan.json")
616
+ # Construct the various report files
617
+ html_report_file = depscan_options.get(
618
+ "html_report_file", os.path.join(reports_dir, "depscan.html")
619
+ )
620
+ pdf_report_file = depscan_options.get(
621
+ "pdf_report_file", os.path.join(reports_dir, "depscan.pdf")
829
622
  )
830
- html_file = areport_file.replace(".json", ".html")
831
- pdf_file = areport_file.replace(".json", ".pdf")
623
+ txt_report_file = depscan_options.get(
624
+ "txt_report_file", os.path.join(reports_dir, "depscan.txt")
625
+ )
626
+ run_config_file = os.path.join(reports_dir, "depscan.toml.sample")
627
+ depscan_options["html_report_file"] = html_report_file
628
+ depscan_options["pdf_report_file"] = pdf_report_file
629
+ depscan_options["txt_report_file"] = txt_report_file
832
630
  # Create reports directory
833
631
  if reports_dir and not os.path.exists(reports_dir):
834
632
  os.makedirs(reports_dir, exist_ok=True)
@@ -846,41 +644,126 @@ def main():
846
644
  expand=False,
847
645
  )
848
646
  )
647
+ # Let’s create a sample configuration file based on the CLI options used.
648
+ if not config_file_mode:
649
+ run_config = {**depscan_options}
650
+ del run_config["no_banner"]
651
+ write_toml(run_config_file, run_config, write_version=False)
652
+ LOG.debug(
653
+ f"Created a sample depscan config file at '{run_config_file}', based on this run."
654
+ )
655
+ # We have everything needed to start the composition analysis. There are many approaches to implementing an SCA tool.
656
+ # Our style of analysis is comparable to that of an intelligent Hubble telescope or a rover—examining the same subject through multiple optics, colors, and depths to gain a deeper understanding.
657
+ # We begin by iterating over the project types provided or assumed.
849
658
  for project_type in project_types_list:
850
659
  results = []
851
- report_file = areport_file.replace(".json", f"-{project_type}.json")
852
- risk_report_file = areport_file.replace(
853
- ".json", f"-risk.{project_type}.json"
660
+ vuln_analyzer = args.vuln_analyzer
661
+ # Are we performing a lifecycle analysis
662
+ if not args.search_purl and (
663
+ vuln_analyzer == "LifecycleAnalyzer"
664
+ or (vuln_analyzer == "auto" and bom_dir_mode)
665
+ ):
666
+ if args.reachability_analyzer == "SemanticReachability":
667
+ if not args.bom_dir:
668
+ LOG.info(
669
+ "Semantic Reachability analysis requested for project type '%s'. This might take a while ...",
670
+ project_type,
671
+ )
672
+ else:
673
+ LOG.info(
674
+ "Attempting semantic analysis using existing data at '%s'",
675
+ args.bom_dir,
676
+ )
677
+ else:
678
+ LOG.info(
679
+ "Lifecycle-based vulnerability analysis requested for project type '%s'. This might take a while ...",
680
+ project_type,
681
+ )
682
+ prebuild_bom_file = os.path.join(
683
+ reports_dir, f"sbom-prebuild-{project_type}.cdx.json"
684
+ )
685
+ build_bom_file = os.path.join(
686
+ reports_dir, f"sbom-build-{project_type}.cdx.json"
687
+ )
688
+ postbuild_bom_file = os.path.join(
689
+ reports_dir, f"sbom-postbuild-{project_type}.cdx.json"
690
+ )
691
+ # We support only one container SBOM per project.
692
+ # Projects that rely on docker compose and multiple services require some thinking
693
+ container_bom_file = os.path.join(
694
+ reports_dir, f"sbom-container-{project_type}.cdx.json"
695
+ )
696
+ operations_bom_file = os.path.join(
697
+ reports_dir, f"sbom-operations-{project_type}.cdx.json"
698
+ )
699
+ if vuln_analyzer == "auto":
700
+ vuln_analyzer = "LifecycleAnalyzer"
701
+ depscan_options["vuln_analyzer"] = "LifecycleAnalyzer"
702
+ # We need to set the following two values to make the rest of the code correctly use
703
+ # the generated BOM files after lifecycle analysis
704
+ depscan_options["lifecycle_analysis_mode"] = True
705
+ if not args.bom_dir:
706
+ args.bom_dir = os.path.realpath(reports_dir)
707
+ # If the user opts out of lifecycle analysis, we need to maintain multiple SBOMs based on the project type.
708
+ bom_file = os.path.join(reports_dir, f"sbom-{project_type}.cdx.json")
709
+ risk_report_file = os.path.join(
710
+ reports_dir, f"depscan-risk-{project_type}.json"
854
711
  )
855
712
  # Are we scanning a single purl
856
713
  if args.search_purl:
857
714
  bom_file = None
858
715
  creation_status = True
859
716
  # Are we scanning a bom file
717
+ ###################
718
+ # Note to students and researchers benchmarking depscan:
719
+ # we’ve seen attempts to run depscan using SBOMs generated by tools like Syft, Trivy, etc.
720
+ # It’s important to understand that not all SBOMs contain the same level of detail.
721
+ # Component PURLs can differ slightly, especially in qualifiers.
722
+ #
723
+ # For container SBOMs, qualifiers like distro_name and distro_version are critical for accurate results.
724
+ # Tools like Syft and Trivy often include internal metadata—such as vendor IDs or fabricated CPE strings—to brute-force vulnerability matches.
725
+ # Because of these inconsistencies, it’s not possible to achieve identical results with depscan when using a non-cdxgen or non-blint SBOM.
726
+ # If in doubt, speak to us before benchmarking depscan. Don’t run depscan with default settings and expect magic.
727
+ # SCA and xBOM are complex domains that require understanding, configuration, and continuous learning.
728
+ ###################
860
729
  elif args.bom and os.path.exists(args.bom):
861
730
  bom_file = args.bom
862
731
  creation_status = True
732
+ # Are we scanning a bom directory
733
+ elif bom_dir_mode:
734
+ bom_file = None
735
+ creation_status = True
863
736
  else:
864
- if args.profile in ("appsec", "research"):
865
- # The bom file has to be called bom.json for atom reachables to work :(
866
- bom_file = os.path.join(src_dir, "bom.json")
867
- else:
868
- bom_file = report_file.replace("depscan-", "sbom-")
737
+ # Create a bom for each project type
869
738
  creation_status = create_bom(
870
- project_type,
871
739
  bom_file,
872
740
  src_dir,
873
- args.deep_scan,
874
741
  {
875
- "cdxgen_server": args.cdxgen_server,
876
- "profile": args.profile,
877
- "cdxgen_args": args.cdxgen_args,
742
+ **depscan_options,
743
+ "project_type": [project_type],
744
+ "bom_file": bom_file,
745
+ "prebuild_bom_file": prebuild_bom_file,
746
+ "build_bom_file": build_bom_file,
747
+ "postbuild_bom_file": postbuild_bom_file,
748
+ "container_bom_file": container_bom_file,
749
+ "operations_bom_file": operations_bom_file,
878
750
  },
879
751
  )
880
752
  if not creation_status:
881
- LOG.debug("Bom file %s was not created successfully", bom_file)
753
+ LOG.warning(
754
+ "The BOM file `%s` was not created successfully. Set the `SCAN_DEBUG_MODE=debug` environment variable to troubleshoot.",
755
+ bom_file,
756
+ )
882
757
  continue
883
- if bom_file:
758
+ # We have a BOM directory. Let’s aggregate all packages from every file within it.
759
+ if args.bom_dir:
760
+ LOG.debug(
761
+ "Collecting components from all the BOM files at %s",
762
+ args.bom_dir,
763
+ )
764
+ pkg_list = get_all_pkg_list(args.bom_dir)
765
+ # We are working with a single BOM file and will collect all packages from it accordingly.
766
+ elif bom_file:
884
767
  LOG.debug("Scanning using the bom file %s", bom_file)
885
768
  if not args.bom:
886
769
  LOG.info(
@@ -888,11 +771,15 @@ def main():
888
771
  "depscan with --bom %s instead of -i",
889
772
  bom_file,
890
773
  )
891
- pkg_list = get_pkg_list(bom_file)
892
- if not pkg_list:
893
- LOG.debug("No packages found in the project!")
774
+ pkg_list, _ = get_pkg_list(bom_file)
775
+ if not pkg_list and not args.bom_dir:
776
+ LOG.info(
777
+ "No packages were found in the project. Try generating the BOM manually or use the `CdxgenImageBasedGenerator` engine."
778
+ )
894
779
  continue
895
- scoped_pkgs = utils.get_pkgs_by_scope(pkg_list)
780
+ # Depending on the SBOM tool used, there may be details about component usage and scopes. Let’s analyze and interpret that information.
781
+ scoped_pkgs = get_pkgs_by_scope(pkg_list)
782
+ # Is the user interested in seeing license risks? Handle that first before any security-related analysis.
896
783
  if (
897
784
  os.getenv("FETCH_LICENSE", "") in (True, "1", "true")
898
785
  or "license" in args.profile
@@ -902,50 +789,51 @@ def main():
902
789
  pkg_list=pkg_list,
903
790
  )
904
791
  license_report_file = os.path.join(
905
- reports_dir, "license-" + project_type + ".json"
792
+ reports_dir, f"license-{project_type}.json"
906
793
  )
907
- analyse_licenses(
794
+ ltable = licenses_risk_table(
908
795
  project_type, licenses_results, license_report_file
909
796
  )
910
- if project_type in risk_audit_map:
911
- if perform_risk_audit:
912
- if len(pkg_list) > 1:
913
- console.print(
914
- Panel(
915
- f"Performing OSS Risk Audit for packages from "
916
- f"{src_dir}\nNo of packages [bold]{len(pkg_list)}"
917
- f"[/bold]. This will take a while ...",
918
- title="OSS Risk Audit",
919
- expand=False,
920
- )
921
- )
922
- try:
923
- risk_results = risk_audit(
924
- project_type,
925
- scoped_pkgs,
926
- args.private_ns,
927
- pkg_list,
928
- )
929
- analyse_pkg_risks(
930
- project_type,
931
- scoped_pkgs,
932
- risk_results,
933
- risk_report_file,
934
- )
935
- except Exception as e:
936
- LOG.error(e)
937
- LOG.error("Risk audit was not successful")
938
- else:
797
+ if ltable and not args.no_vuln_table:
798
+ console.print(ltable)
799
+ # Do we support OSS risk audit for this type? If yes, proceed with the relevant checks.
800
+ if perform_risk_audit and project_type in risk_audit_map:
801
+ if len(pkg_list) > 1:
939
802
  console.print(
940
803
  Panel(
941
- "Depscan supports OSS Risk audit for this "
942
- "project.\nTo enable set the environment variable ["
943
- "bold]ENABLE_OSS_RISK=true[/bold]",
944
- title="Risk Audit Capability",
804
+ f"Performing OSS Risk Audit for packages from "
805
+ f"{src_dir}\nNo of packages [bold]{len(pkg_list)}"
806
+ f"[/bold]. This will take a while ...",
807
+ title="OSS Risk Audit",
945
808
  expand=False,
946
809
  )
947
810
  )
948
- if project_type in type_audit_map:
811
+ try:
812
+ risk_results = risk_audit(
813
+ project_type,
814
+ scoped_pkgs,
815
+ args.private_ns,
816
+ pkg_list,
817
+ )
818
+ rtable, report_data = pkg_risks_table(
819
+ project_type,
820
+ scoped_pkgs,
821
+ risk_results,
822
+ pkg_max_risk_score=pkg_max_risk_score,
823
+ risk_report_file=risk_report_file,
824
+ )
825
+ if not args.no_vuln_table and report_data:
826
+ console.print(rtable)
827
+ except Exception as e:
828
+ LOG.error(e)
829
+ LOG.error("Risk audit was not successful")
830
+ # Do we support remote audit for this type?
831
+ # Remote audits can improve results for some project types like npm by fetching vulnerabilities that might not yet be in our database.
832
+ # In v6, remote audit is disabled by default and gets enabled with risk audit
833
+ #
834
+ # NOTE: Enabling risk audit may lead to some precision loss in reachability results.
835
+ # This is a known limitation with no immediate plan for resolution.
836
+ if perform_risk_audit and project_type in type_audit_map:
949
837
  LOG.debug(
950
838
  "Performing remote audit for %s of type %s",
951
839
  src_dir,
@@ -955,9 +843,7 @@ def main():
955
843
  try:
956
844
  audit_results = audit(project_type, pkg_list)
957
845
  if audit_results:
958
- LOG.debug(
959
- "Remote audit yielded %d results", len(audit_results)
960
- )
846
+ LOG.debug("Remote audit yielded %d results", len(audit_results))
961
847
  results = results + audit_results
962
848
  except Exception as e:
963
849
  LOG.error("Remote audit was not successful")
@@ -965,7 +851,7 @@ def main():
965
851
  results = []
966
852
  # In case of docker, bom, or universal type, check if there are any
967
853
  # npm packages that can be audited remotely
968
- if project_type in (
854
+ if perform_risk_audit and project_type in (
969
855
  "podman",
970
856
  "docker",
971
857
  "oci",
@@ -987,129 +873,135 @@ def main():
987
873
  except Exception as e:
988
874
  LOG.error("Remote audit was not successful")
989
875
  LOG.error(e)
990
- if not db_lib.index_count(db["index_file"]):
991
- run_cacher = True
992
876
  else:
993
- LOG.debug(
994
- "Vulnerability database loaded from %s", config.vdb_bin_file
995
- )
996
-
997
- sources_list = [OSVSource(), NvdSource()]
998
- github_token = os.environ.get("GITHUB_TOKEN")
999
- if github_token and os.getenv("CI"):
1000
- try:
1001
- github_client = github.GitHub(github_token)
1002
-
1003
- if not github_client.can_authenticate():
1004
- LOG.info(
1005
- "The GitHub personal access token supplied appears to be invalid or expired. Please see: https://github.com/owasp-dep-scan/dep-scan#github-security-advisory"
1006
- )
1007
- else:
1008
- sources_list.insert(0, GitHubSource())
1009
- scopes = github_client.get_token_scopes()
1010
- if scopes:
1011
- LOG.warning(
1012
- "The GitHub personal access token was granted more permissions than is necessary for depscan to operate, including the scopes of: %s. It is recommended to use a dedicated token with only the minimum scope necesary for depscan to operate. Please see: https://github.com/owasp-dep-scan/dep-scan#github-security-advisory",
1013
- ", ".join(scopes),
1014
- )
1015
- except Exception:
1016
- pass
1017
- if run_cacher:
1018
- paths_list = download_image()
1019
- LOG.debug("VDB data is stored at: %s", paths_list)
1020
- run_cacher = False
1021
- db = db_lib.get()
1022
- elif args.sync:
1023
- for s in sources_list:
1024
- LOG.debug("Syncing %s", s.__class__.__name__)
1025
- try:
1026
- s.download_recent()
1027
- except NotImplementedError:
1028
- pass
1029
- run_cacher = False
877
+ LOG.debug("Vulnerability database loaded from %s", config.VDB_BIN_FILE)
1030
878
  if len(pkg_list) > 1:
1031
- LOG.info(
1032
- "Performing regular scan for %s using plugin %s",
1033
- src_dir,
1034
- project_type,
1035
- )
1036
- vdb_results, pkg_aliases, sug_version_dict, purl_aliases = scan(
1037
- db, project_type, pkg_list, args.suggest
1038
- )
1039
- if vdb_results:
1040
- results += vdb_results
1041
- results = [r.to_dict() for r in results]
1042
- direct_purls, reached_purls = find_purl_usages(
1043
- bom_file, src_dir, args.reachables_slices_file
879
+ if project_type == "bom":
880
+ LOG.info("Scanning CycloneDX xBOMs and atom slices")
881
+ elif args.bom:
882
+ LOG.info(
883
+ "Scanning %s with type %s",
884
+ args.bom,
885
+ project_type,
886
+ )
887
+ else:
888
+ LOG.info(
889
+ "Scanning %s with type %s",
890
+ src_dir,
891
+ project_type,
892
+ )
893
+ # We could be dealing with multiple bom files
894
+ bom_files = (
895
+ get_all_bom_files(args.bom_dir)
896
+ if args.bom_dir
897
+ else [bom_file]
898
+ if bom_file
899
+ else []
1044
900
  )
1045
- # Summarise and print results
1046
- summary, vdr_file, pkg_vulnerabilities, pkg_group_rows = summarise(
901
+ if not pkg_list and not bom_files:
902
+ LOG.debug("Empty package search attempted!")
903
+ elif bom_files:
904
+ LOG.debug("Scanning %d bom files for issues", len(bom_files))
905
+ else:
906
+ LOG.debug("Scanning %d oss dependencies for issues", len(pkg_list))
907
+ # There are many ways to perform reachability analysis.
908
+ # Most tools—including commercial ones—rely on a vulnerability database with affected modules (sinks) to detect reachable flows.
909
+ # This has several downsides:
910
+ # 1. These databases are often incomplete and manually maintained.
911
+ # 2. If a CVE or ADP enhancement isn’t available yet, reachability won’t be detected.
912
+ #
913
+ # In contrast, depscan computes reachable flows (via atom) without relying on vulnerability data upfront.
914
+ # It then identifies a smaller subset of those flows that are actually vulnerable.
915
+ # From there, we can further narrow it down to flows that are Endpoint-Reachable, Exploitable, Container-Escapable, etc.
916
+ reachability_analyzer = depscan_options.get("reachability_analyzer")
917
+ reachability_options = None
918
+ if (
919
+ reachability_analyzer and reachability_analyzer != "off"
920
+ ) or depscan_options.get("profile") != "generic":
921
+ reachability_options = ReachabilityAnalysisKV(
922
+ project_types=[project_type],
923
+ src_dir=src_dir,
924
+ bom_dir=args.bom_dir or reports_dir,
925
+ require_multi_usage=depscan_options.get("require_multi_usage", False),
926
+ source_tags=depscan_options.get("source_tags"),
927
+ sink_tags=depscan_options.get("sink_tags"),
928
+ )
929
+ # Let’s proceed with the VDR analysis.
930
+ summary, vdr_file, vdr_result = vdr_analyze_summarize(
1047
931
  project_type,
1048
932
  results,
1049
- pkg_aliases,
1050
- purl_aliases,
1051
- sug_version_dict,
933
+ suggest_mode=args.suggest,
1052
934
  scoped_pkgs=scoped_pkgs,
1053
- report_file=report_file,
1054
- bom_file=bom_file,
935
+ bom_file=bom_files[0] if len(bom_files) == 1 else None,
936
+ bom_dir=args.bom_dir,
937
+ pkg_list=pkg_list,
938
+ reachability_analyzer=reachability_analyzer,
939
+ reachability_options=reachability_options,
1055
940
  no_vuln_table=args.no_vuln_table,
1056
- direct_purls=direct_purls,
1057
- reached_purls=reached_purls,
941
+ fuzzy_search=depscan_options.get("fuzzy_search", False),
942
+ search_order=depscan_options.get("search_order"),
1058
943
  )
944
+ if vdr_result.pkg_vulnerabilities:
945
+ all_pkg_vulnerabilities += vdr_result.pkg_vulnerabilities
946
+ if vdr_result.prioritized_pkg_vuln_trees:
947
+ all_pkg_group_rows.update(vdr_result.prioritized_pkg_vuln_trees)
1059
948
  # Explain the results
1060
949
  if args.explain:
1061
950
  explainer.explain(
1062
951
  project_type,
1063
952
  src_dir,
1064
- args.reachables_slices_file,
953
+ args.bom_dir or reports_dir,
1065
954
  vdr_file,
1066
- pkg_vulnerabilities,
1067
- pkg_group_rows,
1068
- direct_purls,
1069
- reached_purls,
955
+ vdr_result,
956
+ args.explanation_mode,
957
+ )
958
+ else:
959
+ LOG.debug(
960
+ "Pass the `--explain` argument to get a detailed explanation of the analysis."
1070
961
  )
1071
962
  # CSAF VEX export
1072
963
  if args.csaf:
1073
964
  export_csaf(
1074
- pkg_vulnerabilities,
965
+ vdr_result,
1075
966
  src_dir,
1076
967
  reports_dir,
1077
- bom_file,
968
+ vdr_file,
1078
969
  )
970
+ console.record = True
971
+ # Export the console output
1079
972
  console.save_html(
1080
- html_file,
1081
- theme=(
1082
- MONOKAI if os.getenv("USE_DARK_THEME") else DEFAULT_TERMINAL_THEME
1083
- ),
973
+ html_report_file,
974
+ clear=False,
975
+ theme=(MONOKAI if os.getenv("USE_DARK_THEME") else DEFAULT_TERMINAL_THEME),
1084
976
  )
1085
- utils.export_pdf(html_file, pdf_file)
977
+ console.save_text(txt_report_file, clear=False)
978
+ utils.export_pdf(html_report_file, pdf_report_file)
979
+ # This logic needs refactoring
1086
980
  # render report into template if wished
1087
981
  if args.report_template and os.path.isfile(args.report_template):
1088
982
  utils.render_template_report(
1089
983
  vdr_file=vdr_file,
1090
984
  bom_file=bom_file,
1091
- pkg_vulnerabilities=pkg_vulnerabilities,
1092
- pkg_group_rows=pkg_group_rows,
985
+ pkg_vulnerabilities=all_pkg_vulnerabilities,
986
+ pkg_group_rows=all_pkg_group_rows,
1093
987
  summary=summary,
1094
988
  template_file=args.report_template,
1095
989
  result_file=os.path.join(reports_dir, args.report_name),
990
+ depscan_options=depscan_options,
1096
991
  )
1097
992
  elif args.report_template:
1098
993
  LOG.warning(
1099
994
  "Template file %s doesn't exist, custom report not created.",
1100
995
  args.report_template,
1101
996
  )
1102
- # Submit vdr/vex files to threatdb server
1103
- if args.threatdb_server and (args.threatdb_username or args.threatdb_token):
1104
- submit_bom(
1105
- reports_dir,
1106
- {
1107
- "threatdb_server": args.threatdb_server,
1108
- "threatdb_username": args.threatdb_username,
1109
- "threatdb_password": args.threatdb_password,
1110
- "threatdb_token": args.threatdb_token,
1111
- },
1112
- )
997
+ # Should we include the generated text report as an annotation in the VDR file?
998
+ if args.explain or args.annotate:
999
+ annotate_vdr(vdr_file, txt_report_file)
1000
+
1001
+
1002
+ def main():
1003
+ cli_args = build_args()
1004
+ run_depscan(cli_args)
1113
1005
 
1114
1006
 
1115
1007
  if __name__ == "__main__":