ds-server-lib 6.1.0__tar.gz → 6.3.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ds-server-lib
3
- Version: 6.1.0
3
+ Version: 6.3.0
4
4
  Summary: Server library for owasp depscan
5
5
  Author-email: Team AppThreat <cloud@appthreat.com>
6
6
  License-Expression: MIT
@@ -15,11 +15,12 @@ Classifier: Programming Language :: Python :: 3.10
15
15
  Classifier: Programming Language :: Python :: 3.11
16
16
  Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
18
19
  Classifier: Topic :: Security
19
20
  Classifier: Topic :: Utilities
20
21
  Requires-Python: >=3.10
21
22
  Description-Content-Type: text/markdown
22
- Requires-Dist: appthreat-vulnerability-db>=6.6.0
23
+ Requires-Dist: appthreat-vulnerability-db~=6.7.2
23
24
  Requires-Dist: custom-json-diff>=2.1.6
24
25
  Requires-Dist: quart>=0.20.0
25
26
  Requires-Dist: rich>=13.9.4
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "ds-server-lib"
3
- version = "6.1.0"
3
+ version = "6.3.0"
4
4
  description = "Server library for owasp depscan"
5
5
  authors = [
6
6
  {name = "Team AppThreat", email = "cloud@appthreat.com"},
7
7
  ]
8
8
  dependencies = [
9
- "appthreat-vulnerability-db>=6.6.0",
9
+ "appthreat-vulnerability-db~=6.7.2",
10
10
  "custom-json-diff>=2.1.6",
11
11
  "quart>=0.20.0",
12
12
  "rich>=13.9.4",
@@ -25,6 +25,7 @@ classifiers = [
25
25
  "Programming Language :: Python :: 3.11",
26
26
  "Programming Language :: Python :: 3.12",
27
27
  "Programming Language :: Python :: 3.13",
28
+ "Programming Language :: Python :: 3.14",
28
29
  "Topic :: Security",
29
30
  "Topic :: Utilities",
30
31
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ds-server-lib
3
- Version: 6.1.0
3
+ Version: 6.3.0
4
4
  Summary: Server library for owasp depscan
5
5
  Author-email: Team AppThreat <cloud@appthreat.com>
6
6
  License-Expression: MIT
@@ -15,11 +15,12 @@ Classifier: Programming Language :: Python :: 3.10
15
15
  Classifier: Programming Language :: Python :: 3.11
16
16
  Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
18
19
  Classifier: Topic :: Security
19
20
  Classifier: Topic :: Utilities
20
21
  Requires-Python: >=3.10
21
22
  Description-Content-Type: text/markdown
22
- Requires-Dist: appthreat-vulnerability-db>=6.6.0
23
+ Requires-Dist: appthreat-vulnerability-db~=6.7.2
23
24
  Requires-Dist: custom-json-diff>=2.1.6
24
25
  Requires-Dist: quart>=0.20.0
25
26
  Requires-Dist: rich>=13.9.4
@@ -1,4 +1,4 @@
1
- appthreat-vulnerability-db>=6.6.0
1
+ appthreat-vulnerability-db~=6.7.2
2
2
  custom-json-diff>=2.1.6
3
3
  quart>=0.20.0
4
4
  rich>=13.9.4
@@ -0,0 +1,553 @@
1
+ import contextlib
2
+ import ipaddress
3
+ import json
4
+ import os
5
+ import socket
6
+ import tempfile
7
+ from hmac import compare_digest
8
+ from urllib.parse import urlparse
9
+
10
+ from analysis_lib import VdrAnalysisKV
11
+ from analysis_lib.utils import get_pkg_list
12
+ from analysis_lib.vdr import VDRAnalyzer
13
+ from custom_json_diff.lib.utils import file_write
14
+ from quart import Quart, request
15
+ from rich.console import Console
16
+ from server_lib import ServerOptions
17
+ from vdb.lib import search
18
+
19
+ app = Quart(f"dep-scan server ({__name__})", static_folder=None)
20
+ app.config.from_prefixed_env(prefix="DEPSCAN_SERVER")
21
+ app.config["PROVIDE_AUTOMATIC_OPTIONS"] = True
22
+
23
+
24
+ def get_allowed_git_schemes(default_schemes=None):
25
+ if default_schemes is None:
26
+ default_schemes = {"http", "https", "git", "git+http", "git+https"}
27
+ env_var_value = os.getenv("DEPSCAN_SERVER_ALLOWED_GIT_SCHEMES")
28
+ if env_var_value is not None:
29
+ return {scheme.strip() for scheme in env_var_value.split(",") if scheme.strip()}
30
+ return default_schemes
31
+
32
+
33
+ allowed_git_schemes = get_allowed_git_schemes()
34
+
35
+
36
+ console = Console(
37
+ log_time=False,
38
+ log_path=False,
39
+ color_system=os.getenv("CONSOLE_COLOR_SCHEME", "256"),
40
+ tab_size=2,
41
+ emoji=os.getenv("DISABLE_CONSOLE_EMOJI", "") not in ("true", "1"),
42
+ )
43
+
44
+ MAX_PROJECT_TYPES = 32
45
+ MAX_PROJECT_TYPE_LENGTH = 64
46
+ DEFAULT_MAX_BOM_FILE_SIZE = 100 * 1024 * 1024
47
+
48
+ UPLOAD_SIZE_LIMIT_MESSAGE = "BOM file exceeds the configured size limit."
49
+
50
+
51
+ def _is_truthy(value):
52
+ if isinstance(value, bool):
53
+ return value
54
+ if value is None:
55
+ return False
56
+ return str(value).strip().lower() in ("true", "1", "yes", "on")
57
+
58
+
59
+ def _is_local_host(host: str | None) -> bool:
60
+ if not host:
61
+ return False
62
+ if host in (
63
+ "127.0.0.1",
64
+ "localhost",
65
+ "0000:0000:0000:0000:0000:0000:0000:0001",
66
+ "0:0:0:0:0:0:0:1",
67
+ "::1",
68
+ ):
69
+ return True
70
+ try:
71
+ return ipaddress.ip_address(host).is_loopback
72
+ except ValueError:
73
+ return False
74
+
75
+
76
+ def _is_allowed_scan_path(path: str, allowed_paths: list[str]) -> bool:
77
+ try:
78
+ real_path = os.path.realpath(path)
79
+ except (OSError, ValueError):
80
+ return False
81
+ for allowed_path in allowed_paths:
82
+ try:
83
+ real_allowed_path = os.path.realpath(allowed_path)
84
+ if os.path.commonpath((real_path, real_allowed_path)) == real_allowed_path:
85
+ return True
86
+ except (OSError, ValueError):
87
+ continue
88
+ return False
89
+
90
+
91
+ def _get_config_int(config, *keys: str, default: int, logger=None) -> int:
92
+ for key in keys:
93
+ value = config.get(key)
94
+ if value in (None, ""):
95
+ continue
96
+ try:
97
+ return int(value)
98
+ except (TypeError, ValueError):
99
+ if logger:
100
+ logger.warning("Ignoring invalid integer config for %s: %r", key, value)
101
+ return default
102
+
103
+
104
+ def _is_private_or_local_ip(ip_text: str) -> bool:
105
+ ip_obj = ipaddress.ip_address(ip_text)
106
+ return any(
107
+ (
108
+ ip_obj.is_private,
109
+ ip_obj.is_loopback,
110
+ ip_obj.is_link_local,
111
+ ip_obj.is_multicast,
112
+ ip_obj.is_reserved,
113
+ ip_obj.is_unspecified,
114
+ )
115
+ )
116
+
117
+
118
+ def _is_private_target(hostname: str | None) -> bool:
119
+ if not hostname:
120
+ return True
121
+ host = hostname.strip("[]")
122
+ try:
123
+ return _is_private_or_local_ip(host)
124
+ except ValueError:
125
+ pass
126
+ try:
127
+ addr_info = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
128
+ except socket.gaierror:
129
+ return True
130
+ resolved_ips = {entry[4][0] for entry in addr_info if entry[4]}
131
+ return not resolved_ips or any(_is_private_or_local_ip(ip_text) for ip_text in resolved_ips)
132
+
133
+
134
+ def _validate_remote_url(url: str, allowed_schemes: set[str], allow_private_urls: bool):
135
+ parsed = urlparse(url)
136
+ if not parsed.scheme or parsed.scheme not in allowed_schemes:
137
+ return False, "URL scheme is not allowed."
138
+ if not parsed.hostname:
139
+ return False, "URL host is required."
140
+ if not allow_private_urls and _is_private_target(parsed.hostname):
141
+ return False, "URL host resolves to a private, loopback, or otherwise restricted address."
142
+ return True, ""
143
+
144
+
145
+ def _parse_project_types(project_type_value: str) -> list[str]:
146
+ project_types = []
147
+ for project_type in (item.strip() for item in project_type_value.split(",")):
148
+ if not project_type:
149
+ continue
150
+ if len(project_type) > MAX_PROJECT_TYPE_LENGTH:
151
+ raise ValueError("project type is too long")
152
+ if not all(ch.isalnum() or ch in ("-", "_", ".", "+") for ch in project_type):
153
+ raise ValueError("project type contains unsupported characters")
154
+ project_types.append(project_type)
155
+ if len(project_types) > MAX_PROJECT_TYPES:
156
+ raise ValueError("too many project types were supplied")
157
+ if not project_types:
158
+ raise ValueError("project type is required")
159
+ return project_types
160
+
161
+
162
+ def _load_bom_data(bom_file_path: str, max_bytes: int):
163
+ try:
164
+ if max_bytes and os.path.getsize(bom_file_path) > max_bytes:
165
+ return None, UPLOAD_SIZE_LIMIT_MESSAGE
166
+ with open(bom_file_path, encoding="utf-8") as bom_fp:
167
+ bom_data = json.load(bom_fp)
168
+ except (FileNotFoundError, OSError, UnicodeDecodeError, json.JSONDecodeError):
169
+ return None, "Unable to read the BOM file."
170
+ if not isinstance(bom_data, dict) or bom_data.get("bomFormat") != "CycloneDX":
171
+ return None, "Uploaded file is not a valid CycloneDX BOM."
172
+ return bom_data, ""
173
+
174
+
175
+ def _read_upload_with_limit(upload, max_bytes: int):
176
+ declared_size = getattr(upload, "content_length", None)
177
+ if declared_size not in (None, ""):
178
+ with contextlib.suppress(TypeError, ValueError):
179
+ if max_bytes and int(declared_size) > max_bytes:
180
+ return None, UPLOAD_SIZE_LIMIT_MESSAGE
181
+
182
+ stream = getattr(upload, "stream", None) or upload
183
+ reader = getattr(stream, "read", None)
184
+ if not callable(reader):
185
+ return None, "Unable to read uploaded file."
186
+
187
+ if not max_bytes:
188
+ return reader(), ""
189
+
190
+ max_bytes = int(max_bytes)
191
+ bytes_remaining = max_bytes + 1
192
+ chunks = []
193
+ while bytes_remaining > 0:
194
+ chunk = reader(min(65536, bytes_remaining))
195
+ if not chunk:
196
+ break
197
+ if isinstance(chunk, str):
198
+ chunk = chunk.encode("utf-8")
199
+ elif not isinstance(chunk, bytes):
200
+ chunk = bytes(chunk)
201
+ chunks.append(chunk)
202
+ bytes_remaining -= len(chunk)
203
+ upload_content = b"".join(chunks)
204
+ if len(upload_content) > max_bytes:
205
+ return None, UPLOAD_SIZE_LIMIT_MESSAGE
206
+ return upload_content, ""
207
+
208
+
209
+ def _get_request_api_key() -> str | None:
210
+ auth_header = request.headers.get("Authorization", "")
211
+ if auth_header.startswith("Bearer "):
212
+ return auth_header.removeprefix("Bearer ").strip()
213
+ return request.headers.get("X-API-Key")
214
+
215
+
216
+ @app.get("/")
217
+ async def index():
218
+ """
219
+ :return: An empty dictionary
220
+ """
221
+ return {}
222
+
223
+
224
+ @app.before_request
225
+ async def enforce_allowlists():
226
+ logger_instance = app.config.get("LOGGER_INSTANCE")
227
+ configured_api_key = app.config.get("API_KEY")
228
+ if configured_api_key:
229
+ request_api_key = _get_request_api_key()
230
+ if not request_api_key or not compare_digest(str(configured_api_key), request_api_key):
231
+ if logger_instance:
232
+ logger_instance.warning("Blocked request with missing or invalid API key.")
233
+ return {"error": "true", "message": "Authentication required"}, 401
234
+ is_testing = bool(os.getenv("PYTEST_CURRENT_TEST"))
235
+ if is_testing:
236
+ client_host = request.headers.get("X-Test-Remote-Addr")
237
+ else:
238
+ client_host = request.remote_addr
239
+ allowed_hosts = app.config.get("ALLOWED_HOSTS")
240
+ if allowed_hosts is not None:
241
+ if not client_host or client_host not in allowed_hosts:
242
+ if logger_instance:
243
+ logger_instance.warning(f"Blocked request from unauthorized host: {client_host}")
244
+ return {"error": "true", "message": "Host not allowed"}, 403
245
+ if request.path == "/scan":
246
+ allowed_paths = app.config.get("ALLOWED_PATHS")
247
+ if allowed_paths is not None:
248
+ path = None
249
+ if request.args.get("path"):
250
+ path = request.args.get("path")
251
+ elif request.method == "POST":
252
+ json_data = await request.get_json(silent=True)
253
+ if isinstance(json_data, dict) and "path" in json_data:
254
+ path = json_data["path"]
255
+ if path:
256
+ if not _is_allowed_scan_path(path, allowed_paths):
257
+ if logger_instance:
258
+ logger_instance.warning(f"Blocked request for path: {path}")
259
+ return {"error": "true", "message": "Path not allowed"}, 403
260
+
261
+
262
+ @app.after_request
263
+ async def add_security_headers(response):
264
+ response.headers["X-Content-Type-Options"] = "nosniff"
265
+ response.headers["X-Frame-Options"] = "SAMEORIGIN"
266
+ response.headers["X-XSS-Protection"] = "1; mode=block"
267
+ if request.scheme == "https":
268
+ response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
269
+ return response
270
+
271
+
272
+ @app.route("/scan", methods=["GET", "POST"])
273
+ async def run_scan():
274
+ """
275
+ :return: A JSON response containing the SBOM file path and a list of
276
+ vulnerabilities found in the scanned packages
277
+ """
278
+ logger_instance = app.config.get("LOGGER_INSTANCE")
279
+ q = request.args
280
+ params = await request.get_json(silent=True)
281
+ if not isinstance(params, dict):
282
+ params = {}
283
+ uploaded_bom_file = await request.files
284
+ create_bom = app.config.get("create_bom")
285
+ allow_private_urls = _is_truthy(app.config.get("ALLOW_PRIVATE_URLS"))
286
+ max_bom_file_size = _get_config_int(
287
+ app.config,
288
+ "MAX_BOM_FILE_SIZE",
289
+ "MAX_CONTENT_LENGTH",
290
+ default=DEFAULT_MAX_BOM_FILE_SIZE,
291
+ logger=logger_instance,
292
+ )
293
+ url = None
294
+ path = None
295
+ multi_project = None
296
+ project_type = None
297
+ results = []
298
+ profile = "generic"
299
+ deep = False
300
+ suggest_mode = _is_truthy(q.get("suggest"))
301
+ fuzzy_search = _is_truthy(q.get("fuzzy_search"))
302
+ temp_paths = []
303
+ if q.get("url"):
304
+ url = q.get("url")
305
+ if q.get("path"):
306
+ path = q.get("path")
307
+ if q.get("multiProject"):
308
+ multi_project = _is_truthy(q.get("multiProject"))
309
+ if q.get("deep"):
310
+ deep = _is_truthy(q.get("deep"))
311
+ if q.get("type"):
312
+ project_type = q.get("type")
313
+ if q.get("profile"):
314
+ profile = q.get("profile")
315
+ if not url and params.get("url"):
316
+ url = params.get("url")
317
+ if not path and params.get("path"):
318
+ path = params.get("path")
319
+ if multi_project is None and params.get("multiProject") is not None:
320
+ multi_project = _is_truthy(params.get("multiProject"))
321
+ if not deep and params.get("deep") is not None:
322
+ deep = _is_truthy(params.get("deep"))
323
+ if not project_type and params.get("type"):
324
+ project_type = params.get("type")
325
+ if params.get("profile"):
326
+ profile = params.get("profile")
327
+
328
+ if not path and not url and (uploaded_bom_file.get("file", None) is None):
329
+ return {
330
+ "error": "true",
331
+ "message": "path or url or a bom file upload is required",
332
+ }, 400
333
+ if not project_type:
334
+ return {"error": "true", "message": "project type is required"}, 400
335
+ try:
336
+ project_type_list = _parse_project_types(project_type)
337
+ except ValueError as exc:
338
+ return {"error": "true", "message": str(exc)}, 400
339
+ cdxgen_server = app.config.get("CDXGEN_SERVER_URL")
340
+ bom_file_path = None
341
+ if uploaded_bom_file.get("file", None) is not None:
342
+ bom_file = uploaded_bom_file["file"]
343
+ bom_file_suffix = str(bom_file.filename).rsplit(".", maxsplit=1)[-1]
344
+ if bom_file_suffix not in ("json", "cdx", "bom"):
345
+ return (
346
+ {
347
+ "error": "true",
348
+ "message": "The uploaded file must be a valid JSON.",
349
+ },
350
+ 400,
351
+ {"Content-Type": "application/json"},
352
+ )
353
+ bom_file_content_raw, bom_read_error = _read_upload_with_limit(bom_file, max_bom_file_size)
354
+ if bom_read_error:
355
+ return (
356
+ {
357
+ "error": "true",
358
+ "message": bom_read_error,
359
+ },
360
+ 400,
361
+ {"Content-Type": "application/json"},
362
+ )
363
+ if isinstance(bom_file_content_raw, bytes):
364
+ try:
365
+ bom_file_content = bom_file_content_raw.decode("utf-8")
366
+ except UnicodeDecodeError:
367
+ return (
368
+ {
369
+ "error": "true",
370
+ "message": "The uploaded file must be valid UTF-8 JSON.",
371
+ },
372
+ 400,
373
+ {"Content-Type": "application/json"},
374
+ )
375
+ else:
376
+ bom_file_content = str(bom_file_content_raw)
377
+ try:
378
+ bom_data = json.loads(bom_file_content)
379
+ if not isinstance(bom_data, dict) or bom_data.get("bomFormat") != "CycloneDX":
380
+ return {
381
+ "error": "true",
382
+ "message": "Uploaded file is not a valid CycloneDX BOM.",
383
+ }, 400
384
+ except (json.JSONDecodeError, KeyError):
385
+ return (
386
+ {
387
+ "error": "true",
388
+ "message": "The uploaded file must be a valid JSON.",
389
+ },
390
+ 400,
391
+ {"Content-Type": "application/json"},
392
+ )
393
+ if logger_instance:
394
+ logger_instance.debug("Processing uploaded file")
395
+ tmp_bom_file = tempfile.NamedTemporaryFile(delete=False, suffix=f".bom.{bom_file_suffix}")
396
+ path = tmp_bom_file.name
397
+ tmp_bom_file.close()
398
+ temp_paths.append(path)
399
+ file_write(path, bom_file_content)
400
+ if url:
401
+ is_valid_url, validation_message = _validate_remote_url(
402
+ url, allowed_git_schemes, allow_private_urls
403
+ )
404
+ if not is_valid_url:
405
+ return {"error": "true", "message": validation_message}, 400
406
+ # Path points to a project directory
407
+ # Bug# 233. Path could be a url
408
+ try:
409
+ if url or (path and os.path.isdir(path)):
410
+ if url and not path and not cdxgen_server:
411
+ return (
412
+ {
413
+ "error": "true",
414
+ "message": "cdxgen server is required to generate SBOM for url.",
415
+ },
416
+ 400,
417
+ {"Content-Type": "application/json"},
418
+ )
419
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".bom.json") as bfp:
420
+ temp_paths.append(bfp.name)
421
+ if create_bom:
422
+ bom_status = create_bom(
423
+ bfp.name,
424
+ path,
425
+ {
426
+ "url": url,
427
+ "path": path,
428
+ "project_type": project_type_list,
429
+ "multiProject": multi_project,
430
+ "cdxgen_server": cdxgen_server,
431
+ "profile": profile,
432
+ "deep": deep,
433
+ },
434
+ )
435
+ if bom_status:
436
+ if logger_instance:
437
+ logger_instance.debug(
438
+ "BOM file was generated successfully at %s", bfp.name
439
+ )
440
+ bom_file_path = bfp.name
441
+ elif logger_instance:
442
+ logger_instance.debug("Problem generating the SBOM for %s %s", url, path)
443
+ # Path points to a SBOM file
444
+ elif path and os.path.exists(path):
445
+ bom_file_path = path
446
+ # Direct purl-based lookups are not supported yet.
447
+ if bom_file_path is not None:
448
+ pkg_list, _ = get_pkg_list(bom_file_path)
449
+ if not pkg_list:
450
+ if logger_instance:
451
+ logger_instance.debug("Empty package search attempted!")
452
+ else:
453
+ if logger_instance:
454
+ logger_instance.debug("Scanning %d oss dependencies for issues", len(pkg_list))
455
+ bom_data, bom_error = _load_bom_data(bom_file_path, max_bom_file_size)
456
+ if not bom_data:
457
+ return (
458
+ {"error": "true", "message": bom_error},
459
+ 400,
460
+ {"Content-Type": "application/json"},
461
+ )
462
+ options = VdrAnalysisKV(
463
+ project_type,
464
+ results,
465
+ pkg_aliases={},
466
+ purl_aliases={},
467
+ suggest_mode=suggest_mode,
468
+ scoped_pkgs={},
469
+ no_vuln_table=True,
470
+ bom_file=bom_file_path,
471
+ pkg_list=[],
472
+ direct_purls={},
473
+ reached_purls={},
474
+ console=console,
475
+ logger=logger_instance,
476
+ fuzzy_search=fuzzy_search,
477
+ )
478
+ vdr_result = VDRAnalyzer(vdr_options=options).process()
479
+ if vdr_result.success:
480
+ pkg_vulnerabilities = vdr_result.pkg_vulnerabilities
481
+ if pkg_vulnerabilities:
482
+ bom_data["vulnerabilities"] = pkg_vulnerabilities
483
+ return json.dumps(bom_data), 200, {"Content-Type": "application/json"}
484
+ if bom_data:
485
+ return json.dumps(bom_data), 200, {"Content-Type": "application/json"}
486
+ return (
487
+ {
488
+ "error": "true",
489
+ "message": "Unable to generate SBOM. Check your input path or url.",
490
+ },
491
+ 500,
492
+ {"Content-Type": "application/json"},
493
+ )
494
+ finally:
495
+ cleanup_temp(*temp_paths)
496
+
497
+
498
+ def cleanup_temp(*temp_files):
499
+ for temp_file in temp_files:
500
+ if not temp_file:
501
+ continue
502
+ candidate = getattr(temp_file, "name", temp_file)
503
+ if not candidate:
504
+ continue
505
+ with contextlib.suppress(FileNotFoundError, OSError, TypeError):
506
+ os.remove(candidate)
507
+
508
+
509
+ def run_server(options: ServerOptions):
510
+ app.config["CDXGEN_SERVER_URL"] = options.cdxgen_server
511
+ app.config["LOGGER_INSTANCE"] = options.logger
512
+ app.config.setdefault("API_KEY", os.getenv("DEPSCAN_SERVER_API_KEY"))
513
+ app.config.setdefault("ALLOW_PRIVATE_URLS", os.getenv("DEPSCAN_SERVER_ALLOW_PRIVATE_URLS"))
514
+ app.config.setdefault(
515
+ "ALLOW_UNAUTHENTICATED_BIND",
516
+ os.getenv("DEPSCAN_SERVER_ALLOW_UNAUTHENTICATED_BIND"),
517
+ )
518
+ if options.allowed_hosts:
519
+ app.config["ALLOWED_HOSTS"] = [h.strip() for h in options.allowed_hosts if h]
520
+ if options.allowed_paths:
521
+ app.config["ALLOWED_PATHS"] = [os.path.realpath(p) for p in options.allowed_paths if p]
522
+ if options.max_content_length:
523
+ app.config["MAX_CONTENT_LENGTH"] = options.max_content_length
524
+ # Dirty hack to get access to the create_bom function
525
+ if options.create_bom:
526
+ app.config["create_bom"] = options.create_bom
527
+ if options.custom_data_directory:
528
+ if options.logger:
529
+ options.logger.info(
530
+ f"Loading custom vulnerability data from {options.custom_data_directory}"
531
+ )
532
+ search.load_custom_data(options.custom_data_directory)
533
+ logger = options.logger
534
+ if logger:
535
+ logger.info(f"dep-scan server running on {options.server_host}:{options.server_port}")
536
+ if not _is_local_host(options.server_host) and not app.config.get("API_KEY"):
537
+ if not _is_truthy(app.config.get("ALLOW_UNAUTHENTICATED_BIND")):
538
+ if logger:
539
+ logger.error(
540
+ "Refusing to bind dep-scan server to a non-local host without DEPSCAN_SERVER_API_KEY or DEPSCAN_SERVER_ALLOW_UNAUTHENTICATED_BIND=true."
541
+ )
542
+ return False
543
+ if logger:
544
+ logger.warning("Server listening on non-local host without built-in authentication.")
545
+ app.run(
546
+ host=options.server_host,
547
+ port=options.server_port,
548
+ debug=options.debug,
549
+ use_reloader=False,
550
+ ca_certs=options.ca_certs,
551
+ certfile=options.certfile,
552
+ keyfile=options.keyfile,
553
+ )