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