ds-server-lib 6.3.0__tar.gz → 6.3.2__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.3.0
3
+ Version: 6.3.2
4
4
  Summary: Server library for owasp depscan
5
5
  Author-email: Team AppThreat <cloud@appthreat.com>
6
6
  License-Expression: MIT
@@ -20,12 +20,12 @@ Classifier: Topic :: Security
20
20
  Classifier: Topic :: Utilities
21
21
  Requires-Python: >=3.10
22
22
  Description-Content-Type: text/markdown
23
- Requires-Dist: appthreat-vulnerability-db~=6.7.2
24
- Requires-Dist: custom-json-diff>=2.1.6
25
- Requires-Dist: quart>=0.20.0
26
- Requires-Dist: rich>=13.9.4
27
- Requires-Dist: ds-analysis-lib
23
+ Requires-Dist: appthreat-vulnerability-db~=6.7.7
24
+ Requires-Dist: custom-json-diff~=2.1.6
25
+ Requires-Dist: quart~=0.20
26
+ Requires-Dist: rich~=15.0.0
27
+ Requires-Dist: ds-analysis-lib~=6.3.2
28
28
  Provides-Extra: dev
29
- Requires-Dist: pytest>=8.3.4; extra == "dev"
30
- Requires-Dist: pytest-asyncio>=1.2.0; extra == "dev"
31
- Requires-Dist: pytest-cov>=6.0.0; extra == "dev"
29
+ Requires-Dist: pytest~=9.2.0; extra == "dev"
30
+ Requires-Dist: pytest-asyncio~=1.4.1; extra == "dev"
31
+ Requires-Dist: pytest-cov~=7.1.0; extra == "dev"
@@ -1,16 +1,16 @@
1
1
  [project]
2
2
  name = "ds-server-lib"
3
- version = "6.3.0"
3
+ version = "6.3.2"
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.7.2",
10
- "custom-json-diff>=2.1.6",
11
- "quart>=0.20.0",
12
- "rich>=13.9.4",
13
- "ds-analysis-lib"
9
+ "appthreat-vulnerability-db~=6.7.7",
10
+ "custom-json-diff~=2.1.6",
11
+ "quart~=0.20",
12
+ "rich~=15.0.0",
13
+ "ds-analysis-lib~=6.3.2"
14
14
  ]
15
15
 
16
16
  requires-python = ">=3.10"
@@ -32,9 +32,9 @@ classifiers = [
32
32
 
33
33
  [project.optional-dependencies]
34
34
  dev = [
35
- "pytest>=8.3.4",
36
- "pytest-asyncio>=1.2.0",
37
- "pytest-cov>=6.0.0",
35
+ "pytest~=9.2.0",
36
+ "pytest-asyncio~=1.4.1",
37
+ "pytest-cov~=7.1.0",
38
38
  ]
39
39
 
40
40
  [project.urls]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ds-server-lib
3
- Version: 6.3.0
3
+ Version: 6.3.2
4
4
  Summary: Server library for owasp depscan
5
5
  Author-email: Team AppThreat <cloud@appthreat.com>
6
6
  License-Expression: MIT
@@ -20,12 +20,12 @@ Classifier: Topic :: Security
20
20
  Classifier: Topic :: Utilities
21
21
  Requires-Python: >=3.10
22
22
  Description-Content-Type: text/markdown
23
- Requires-Dist: appthreat-vulnerability-db~=6.7.2
24
- Requires-Dist: custom-json-diff>=2.1.6
25
- Requires-Dist: quart>=0.20.0
26
- Requires-Dist: rich>=13.9.4
27
- Requires-Dist: ds-analysis-lib
23
+ Requires-Dist: appthreat-vulnerability-db~=6.7.7
24
+ Requires-Dist: custom-json-diff~=2.1.6
25
+ Requires-Dist: quart~=0.20
26
+ Requires-Dist: rich~=15.0.0
27
+ Requires-Dist: ds-analysis-lib~=6.3.2
28
28
  Provides-Extra: dev
29
- Requires-Dist: pytest>=8.3.4; extra == "dev"
30
- Requires-Dist: pytest-asyncio>=1.2.0; extra == "dev"
31
- Requires-Dist: pytest-cov>=6.0.0; extra == "dev"
29
+ Requires-Dist: pytest~=9.2.0; extra == "dev"
30
+ Requires-Dist: pytest-asyncio~=1.4.1; extra == "dev"
31
+ Requires-Dist: pytest-cov~=7.1.0; extra == "dev"
@@ -0,0 +1,10 @@
1
+ appthreat-vulnerability-db~=6.7.7
2
+ custom-json-diff~=2.1.6
3
+ quart~=0.20
4
+ rich~=15.0.0
5
+ ds-analysis-lib~=6.3.2
6
+
7
+ [dev]
8
+ pytest~=9.2.0
9
+ pytest-asyncio~=1.4.1
10
+ pytest-cov~=7.1.0
@@ -18,7 +18,10 @@ class ServerOptions:
18
18
  certfile: Optional[str] = None
19
19
  keyfile: Optional[str] = None
20
20
  debug: bool = False
21
- max_content_length: int = 100 * 1024 * 1024 # 100MB
21
+ # Maximum request body size in bytes. None means no limit is configured
22
+ # (Flask leaves MAX_CONTENT_LENGTH unset); a non-int value such as the raw
23
+ # env string breaks Werkzeug's per-request size comparison.
24
+ max_content_length: Optional[int] = 100 * 1024 * 1024 # 100MB
22
25
  # Hack
23
26
  create_bom: Optional[Callable] = None
24
27
  custom_data_directory: Optional[str] = None
@@ -2,12 +2,17 @@ import contextlib
2
2
  import ipaddress
3
3
  import json
4
4
  import os
5
+ import re
5
6
  import socket
7
+ import sys
6
8
  import tempfile
7
9
  from hmac import compare_digest
8
- from urllib.parse import urlparse
10
+ from typing import cast
11
+ from urllib.parse import urlparse, urlsplit
9
12
 
10
13
  from analysis_lib import VdrAnalysisKV
14
+ from analysis_lib.config import SUPPORTED_BOM_PROFILES
15
+ from analysis_lib.search import get_pkgs_by_scope
11
16
  from analysis_lib.utils import get_pkg_list
12
17
  from analysis_lib.vdr import VDRAnalyzer
13
18
  from custom_json_diff.lib.utils import file_write
@@ -33,10 +38,21 @@ def get_allowed_git_schemes(default_schemes=None):
33
38
  allowed_git_schemes = get_allowed_git_schemes()
34
39
 
35
40
 
41
+ def _color_system():
42
+ """Translate CONSOLE_COLOR_SCHEME into a rich-acceptable value.
43
+
44
+ rich only accepts its fixed color-system names (or None); anything else
45
+ raises KeyError while constructing the Console, so an unrecognized value
46
+ (e.g. a typo) falls back to None for auto-detection.
47
+ """
48
+ scheme = os.getenv("CONSOLE_COLOR_SCHEME", "256")
49
+ return scheme if scheme in ("auto", "standard", "256", "truecolor", "windows") else None
50
+
51
+
36
52
  console = Console(
37
53
  log_time=False,
38
54
  log_path=False,
39
- color_system=os.getenv("CONSOLE_COLOR_SCHEME", "256"),
55
+ color_system=_color_system(),
40
56
  tab_size=2,
41
57
  emoji=os.getenv("DISABLE_CONSOLE_EMOJI", "") not in ("true", "1"),
42
58
  )
@@ -46,6 +62,26 @@ MAX_PROJECT_TYPE_LENGTH = 64
46
62
  DEFAULT_MAX_BOM_FILE_SIZE = 100 * 1024 * 1024
47
63
 
48
64
  UPLOAD_SIZE_LIMIT_MESSAGE = "BOM file exceeds the configured size limit."
65
+ PATH_NOT_ALLOWED_MESSAGE = "Path not allowed"
66
+ PATH_NOT_FOUND_MESSAGE = "path must be an existing directory or BOM file."
67
+ CROSS_SITE_MESSAGE = "Cross-site requests are not allowed."
68
+ UNTRUSTED_HOST_HEADER_MESSAGE = (
69
+ "Host header not allowed. Set DEPSCAN_SERVER_TRUSTED_HOST_NAMES to reach the "
70
+ "server by this name, or set DEPSCAN_SERVER_API_KEY."
71
+ )
72
+ QUERY_SCAN_NEEDS_JSON_MESSAGE = (
73
+ "Send path and url in a JSON body, or set DEPSCAN_SERVER_API_KEY, when the server "
74
+ "is not reached through localhost or a loopback address."
75
+ )
76
+ PATH_NOT_STRING_MESSAGE = "path and url must be strings."
77
+ # A value that cdxgen would read as an option, a URL, a package URL or an scp-style
78
+ # git address rather than a file system path. One letter before ":" is a drive.
79
+ NON_PATH_RE = re.compile(r"^(?:-|[A-Za-z][A-Za-z0-9+.-]+:|git@)")
80
+ WINDOWS_RESERVED_NAMES = frozenset(
81
+ ["CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"]
82
+ + [f"{prefix}{n}" for prefix in ("COM", "LPT") for n in "123456789\u00b9\u00b2\u00b3"]
83
+ )
84
+ SERVER_TEMP_DIR_PREFIX = "depscan-server-"
49
85
 
50
86
 
51
87
  def _is_truthy(value):
@@ -88,6 +124,59 @@ def _is_allowed_scan_path(path: str, allowed_paths: list[str]) -> bool:
88
124
  return False
89
125
 
90
126
 
127
+ def _is_path_within(path: str, parent_dir: str) -> bool:
128
+ """
129
+ Check whether ``path`` resolves to a location inside ``parent_dir``.
130
+
131
+ Both operands are fully resolved first so symlinks and relative segments
132
+ cannot be used to escape the parent directory.
133
+ """
134
+ try:
135
+ real_path = os.path.realpath(path)
136
+ real_parent = os.path.realpath(parent_dir)
137
+ except (OSError, ValueError):
138
+ return False
139
+ try:
140
+ return os.path.commonpath((real_path, real_parent)) == real_parent
141
+ except (OSError, ValueError):
142
+ return False
143
+
144
+
145
+ def _revalidate_scan_path(path: str | None) -> bool:
146
+ """
147
+ Re-check a scan path against the configured allowlist at the point of use.
148
+
149
+ ``enforce_allowlists`` validates the requested path before the request is
150
+ dispatched, but the path is consumed later, once BOM generation is under
151
+ way. Re-resolving it immediately before every filesystem access keeps a
152
+ symlink that appears inside an allowed directory after the initial check
153
+ from redirecting the scan outside the allowlist.
154
+ """
155
+ allowed_paths = app.config.get("ALLOWED_PATHS")
156
+ if allowed_paths is None or not path:
157
+ return True
158
+ return _is_allowed_scan_path(path, allowed_paths)
159
+
160
+
161
+ def _get_server_temp_dir() -> str:
162
+ """
163
+ Return the private directory used for this server's temporary BOM files.
164
+
165
+ The directory is created once per process with ``0o700`` permissions so
166
+ uploaded and generated BOMs are not exposed to other users or to sibling
167
+ containers that happen to share the system temporary directory.
168
+ """
169
+ temp_dir = app.config.get("SERVER_TEMP_DIR")
170
+ if temp_dir and os.path.isdir(temp_dir):
171
+ return temp_dir
172
+ temp_dir = tempfile.mkdtemp(
173
+ prefix=SERVER_TEMP_DIR_PREFIX, dir=os.getenv("DEPSCAN_TEMP_DIR") or None
174
+ )
175
+ os.chmod(temp_dir, 0o700)
176
+ app.config["SERVER_TEMP_DIR"] = temp_dir
177
+ return temp_dir
178
+
179
+
91
180
  def _get_config_int(config, *keys: str, default: int, logger=None) -> int:
92
181
  for key in keys:
93
182
  value = config.get(key)
@@ -127,7 +216,10 @@ def _is_private_target(hostname: str | None) -> bool:
127
216
  addr_info = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
128
217
  except socket.gaierror:
129
218
  return True
130
- resolved_ips = {entry[4][0] for entry in addr_info if entry[4]}
219
+ # The first sockaddr element is documented (and typed in getaddrinfo's
220
+ # docstring) as the resolved address string; typeshed widens it to
221
+ # int | str across the various family-specific overloads.
222
+ resolved_ips = {cast(str, entry[4][0]) for entry in addr_info if entry[4]}
131
223
  return not resolved_ips or any(_is_private_or_local_ip(ip_text) for ip_text in resolved_ips)
132
224
 
133
225
 
@@ -137,6 +229,10 @@ def _validate_remote_url(url: str, allowed_schemes: set[str], allow_private_urls
137
229
  return False, "URL scheme is not allowed."
138
230
  if not parsed.hostname:
139
231
  return False, "URL host is required."
232
+ # Credentials embedded in a scan URL would be handed to the BOM generator
233
+ # and, on a redirect, to whichever host it ends up talking to.
234
+ if parsed.username or parsed.password:
235
+ return False, "URL must not contain embedded credentials."
140
236
  if not allow_private_urls and _is_private_target(parsed.hostname):
141
237
  return False, "URL host resolves to a private, loopback, or otherwise restricted address."
142
238
  return True, ""
@@ -151,6 +247,10 @@ def _parse_project_types(project_type_value: str) -> list[str]:
151
247
  raise ValueError("project type is too long")
152
248
  if not all(ch.isalnum() or ch in ("-", "_", ".", "+") for ch in project_type):
153
249
  raise ValueError("project type contains unsupported characters")
250
+ # Each type follows "-t" on the cdxgen command line, so a value such as
251
+ # "--server" would become an option of its own.
252
+ if not project_type[0].isalnum():
253
+ raise ValueError("project type must start with a letter or digit")
154
254
  project_types.append(project_type)
155
255
  if len(project_types) > MAX_PROJECT_TYPES:
156
256
  raise ValueError("too many project types were supplied")
@@ -206,6 +306,126 @@ def _read_upload_with_limit(upload, max_bytes: int):
206
306
  return upload_content, ""
207
307
 
208
308
 
309
+ def _is_cross_site_request(headers) -> bool:
310
+ """Tell whether a browser sent this request on behalf of another site.
311
+
312
+ A web page can make the operator's browser send GET requests and simple
313
+ POST requests to the server, so a page on any site could start scans on a
314
+ loopback server that has no API key. Browsers mark such requests with
315
+ ``Sec-Fetch-Site`` and, for POST and CORS requests, with ``Origin``.
316
+ ``same-site`` is refused as well, because every port on ``localhost`` is
317
+ the same site. curl, CI jobs and other API clients send neither header and
318
+ are not affected.
319
+ """
320
+ fetch_site = (headers.get("Sec-Fetch-Site") or "").strip().lower()
321
+ if fetch_site:
322
+ # The browser computes this header itself, so when it is present it
323
+ # decides. A reverse proxy that rewrites Host would otherwise make
324
+ # same-origin requests fail the Origin comparison below.
325
+ return fetch_site in ("cross-site", "same-site")
326
+ # Browsers send Sec-Fetch-* only to localhost, loopback and HTTPS
327
+ # addresses; for the rest, Origin is all there is.
328
+ origin = headers.get("Origin")
329
+ if origin is None:
330
+ return False
331
+ origin = origin.strip()
332
+ # Sandboxed frames and file:// pages send the literal "null".
333
+ if not origin or origin.lower() == "null":
334
+ return True
335
+ try:
336
+ origin_netloc = urlsplit(origin).netloc.lower()
337
+ except ValueError:
338
+ return True
339
+ return origin_netloc != (headers.get("Host") or "").strip().lower()
340
+
341
+
342
+ def _trusted_host_names() -> set[str]:
343
+ """Host names from DEPSCAN_SERVER_TRUSTED_HOST_NAMES (a list or comma-separated)."""
344
+ value = app.config.get("TRUSTED_HOST_NAMES")
345
+ if not value:
346
+ return set()
347
+ items = value if isinstance(value, (list, tuple, set)) else str(value).split(",")
348
+ return {str(item).strip().rstrip(".").lower() for item in items if str(item).strip()}
349
+
350
+
351
+ def _host_header_name(host_header: str) -> str | None:
352
+ """Return the lower-case host name from a Host header, without port or trailing dot."""
353
+ try:
354
+ hostname = urlsplit(f"//{host_header.strip()}").hostname
355
+ except ValueError:
356
+ return None
357
+ return hostname.rstrip(".").lower() if hostname else None
358
+
359
+
360
+ def _is_loopback_host_name(hostname: str) -> bool:
361
+ if hostname == "localhost" or hostname.endswith(".localhost"):
362
+ return True
363
+ try:
364
+ address = ipaddress.ip_address(hostname)
365
+ except ValueError:
366
+ return False
367
+ if isinstance(address, ipaddress.IPv6Address) and address.ipv4_mapped:
368
+ address = address.ipv4_mapped
369
+ return address.is_loopback
370
+
371
+
372
+ def _is_loopback_host_header(host_header: str | None) -> bool:
373
+ """Tell whether the request was addressed to localhost or a loopback address.
374
+
375
+ Browsers send ``Sec-Fetch-Site`` to such addresses, so the cross-site
376
+ check covers them. Requests without a Host header come from HTTP/1.0
377
+ clients, not browsers.
378
+ """
379
+ if not host_header:
380
+ return True
381
+ hostname = _host_header_name(host_header)
382
+ return bool(hostname) and _is_loopback_host_name(hostname)
383
+
384
+
385
+ def _is_trusted_host_header(host_header: str | None, trusted_names: set[str]) -> bool:
386
+ """Tell whether the Host header names this server rather than a rebound domain.
387
+
388
+ In a DNS rebinding attack, a page on a domain the attacker controls points
389
+ that domain at 127.0.0.1, so the browser treats requests to the server as
390
+ same-origin and lets the page read the responses. The Host header still
391
+ carries the attacker's domain. ``localhost``, ``*.localhost`` and IP
392
+ literals cannot be rebound, so they are always accepted; any other name
393
+ must be listed in DEPSCAN_SERVER_TRUSTED_HOST_NAMES.
394
+ """
395
+ if not host_header:
396
+ # Browsers always send Host; only HTTP/1.0 clients leave it out.
397
+ return True
398
+ hostname = _host_header_name(host_header)
399
+ if not hostname:
400
+ return False
401
+ if hostname == "localhost" or hostname.endswith(".localhost"):
402
+ return True
403
+ try:
404
+ ipaddress.ip_address(hostname)
405
+ return True
406
+ except ValueError:
407
+ pass
408
+ return hostname in trusted_names
409
+
410
+
411
+ def _is_windows_remote_or_device_path(path: str) -> bool:
412
+ """Tell whether a Windows path names a network share or a device.
413
+
414
+ Checking whether ``\\\\host\\share`` exists makes Windows connect to that
415
+ host over SMB and offer the server account's credentials. Device names
416
+ such as ``CON`` or ``\\\\.\\pipe\\x`` are not files and can block reads.
417
+ """
418
+ # UNC shares, \\?\ and \\.\ device paths, and the NT \??\ prefix, in any
419
+ # mix of slashes.
420
+ if re.match(r"^[\\/]([\\/]|\?\?[\\/])", path):
421
+ return True
422
+ for part in re.split(r"[\\/]", path):
423
+ stem = part.split(".", 1)[0].split(":", 1)[0].rstrip(" ").upper()
424
+ if stem in WINDOWS_RESERVED_NAMES:
425
+ return True
426
+ return False
427
+
428
+
209
429
  def _get_request_api_key() -> str | None:
210
430
  auth_header = request.headers.get("Authorization", "")
211
431
  if auth_header.startswith("Bearer "):
@@ -225,6 +445,23 @@ async def index():
225
445
  async def enforce_allowlists():
226
446
  logger_instance = app.config.get("LOGGER_INSTANCE")
227
447
  configured_api_key = app.config.get("API_KEY")
448
+ if _is_cross_site_request(request.headers):
449
+ if logger_instance:
450
+ logger_instance.warning(
451
+ "Blocked cross-site request (Origin %r, Sec-Fetch-Site %r).",
452
+ request.headers.get("Origin"),
453
+ request.headers.get("Sec-Fetch-Site"),
454
+ )
455
+ return {"error": "true", "message": CROSS_SITE_MESSAGE}, 403
456
+ # An API key already stops a rebound page, which cannot know the key.
457
+ if not configured_api_key and not _is_trusted_host_header(
458
+ request.headers.get("Host"), _trusted_host_names()
459
+ ):
460
+ if logger_instance:
461
+ logger_instance.warning(
462
+ "Blocked request for untrusted Host header %r.", request.headers.get("Host")
463
+ )
464
+ return {"error": "true", "message": UNTRUSTED_HOST_HEADER_MESSAGE}, 403
228
465
  if configured_api_key:
229
466
  request_api_key = _get_request_api_key()
230
467
  if not request_api_key or not compare_digest(str(configured_api_key), request_api_key):
@@ -243,20 +480,34 @@ async def enforce_allowlists():
243
480
  logger_instance.warning(f"Blocked request from unauthorized host: {client_host}")
244
481
  return {"error": "true", "message": "Host not allowed"}, 403
245
482
  if request.path == "/scan":
483
+ # A browser can put path or url in the query string of a cross-site GET
484
+ # or simple POST. Browsers send Sec-Fetch-Site only to loopback and
485
+ # HTTPS addresses, so elsewhere the cross-site check above cannot see
486
+ # such a request, while a JSON body needs a CORS preflight that this
487
+ # server never grants.
488
+ if (
489
+ not configured_api_key
490
+ and (request.args.get("path") or request.args.get("url"))
491
+ and not _is_loopback_host_header(request.headers.get("Host"))
492
+ ):
493
+ return {"error": "true", "message": QUERY_SCAN_NEEDS_JSON_MESSAGE}, 403
494
+ path = request.args.get("path") or None
495
+ if path is None and request.method == "POST":
496
+ json_data = await request.get_json(silent=True)
497
+ if isinstance(json_data, dict) and json_data.get("path"):
498
+ path = json_data["path"]
499
+ if path is not None and not isinstance(path, str):
500
+ return {"error": "true", "message": PATH_NOT_STRING_MESSAGE}, 400
501
+ # Checked before any file system access, including the allowlist below.
502
+ if path and sys.platform == "win32" and _is_windows_remote_or_device_path(path):
503
+ return {"error": "true", "message": PATH_NOT_ALLOWED_MESSAGE}, 403
246
504
  allowed_paths = app.config.get("ALLOWED_PATHS")
247
505
  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
506
  if path:
256
507
  if not _is_allowed_scan_path(path, allowed_paths):
257
508
  if logger_instance:
258
509
  logger_instance.warning(f"Blocked request for path: {path}")
259
- return {"error": "true", "message": "Path not allowed"}, 403
510
+ return {"error": "true", "message": PATH_NOT_ALLOWED_MESSAGE}, 403
260
511
 
261
512
 
262
513
  @app.after_request
@@ -300,6 +551,7 @@ async def run_scan():
300
551
  suggest_mode = _is_truthy(q.get("suggest"))
301
552
  fuzzy_search = _is_truthy(q.get("fuzzy_search"))
302
553
  temp_paths = []
554
+ path_is_caller_supplied = True
303
555
  if q.get("url"):
304
556
  url = q.get("url")
305
557
  if q.get("path"):
@@ -325,6 +577,12 @@ async def run_scan():
325
577
  if params.get("profile"):
326
578
  profile = params.get("profile")
327
579
 
580
+ if (path is not None and not isinstance(path, str)) or (
581
+ url is not None and not isinstance(url, str)
582
+ ):
583
+ return {"error": "true", "message": PATH_NOT_STRING_MESSAGE}, 400
584
+ if project_type is not None and not isinstance(project_type, str):
585
+ return {"error": "true", "message": "project type must be a string."}, 400
328
586
  if not path and not url and (uploaded_bom_file.get("file", None) is None):
329
587
  return {
330
588
  "error": "true",
@@ -336,8 +594,16 @@ async def run_scan():
336
594
  project_type_list = _parse_project_types(project_type)
337
595
  except ValueError as exc:
338
596
  return {"error": "true", "message": str(exc)}, 400
597
+ # The profile is forwarded to cdxgen as a command line argument, so only
598
+ # the documented values are accepted here.
599
+ if profile not in SUPPORTED_BOM_PROFILES:
600
+ return {
601
+ "error": "true",
602
+ "message": f"profile must be one of {', '.join(SUPPORTED_BOM_PROFILES)}.",
603
+ }, 400
339
604
  cdxgen_server = app.config.get("CDXGEN_SERVER_URL")
340
605
  bom_file_path = None
606
+ bom_is_caller_file = False
341
607
  if uploaded_bom_file.get("file", None) is not None:
342
608
  bom_file = uploaded_bom_file["file"]
343
609
  bom_file_suffix = str(bom_file.filename).rsplit(".", maxsplit=1)[-1]
@@ -392,8 +658,15 @@ async def run_scan():
392
658
  )
393
659
  if logger_instance:
394
660
  logger_instance.debug("Processing uploaded file")
395
- tmp_bom_file = tempfile.NamedTemporaryFile(delete=False, suffix=f".bom.{bom_file_suffix}")
661
+ tmp_bom_file = tempfile.NamedTemporaryFile(
662
+ delete=False,
663
+ suffix=f".bom.{bom_file_suffix}",
664
+ dir=_get_server_temp_dir(),
665
+ )
396
666
  path = tmp_bom_file.name
667
+ # The uploaded BOM now lives in the server's own temporary directory,
668
+ # so it is no longer a caller supplied path for allowlist purposes.
669
+ path_is_caller_supplied = False
397
670
  tmp_bom_file.close()
398
671
  temp_paths.append(path)
399
672
  file_write(path, bom_file_content)
@@ -403,9 +676,27 @@ async def run_scan():
403
676
  )
404
677
  if not is_valid_url:
405
678
  return {"error": "true", "message": validation_message}, 400
679
+ # Without a cdxgen server, a caller-supplied path is scanned locally, so it
680
+ # must name an existing directory or BOM file. Any other value would reach
681
+ # the local cdxgen as its source argument, where cdxgen could read it as a
682
+ # git URL or package URL that the url checks above never saw.
683
+ if path and path_is_caller_supplied:
684
+ if NON_PATH_RE.match(path):
685
+ return {"error": "true", "message": PATH_NOT_FOUND_MESSAGE}, 400
686
+ if os.path.exists(path):
687
+ # Windows resolves ".." in the text before touching the disk, so
688
+ # "x:y/../../file" exists even though "x:y" does not. Only the
689
+ # resolved path is passed on.
690
+ path = os.path.realpath(path)
691
+ elif not cdxgen_server:
692
+ return {"error": "true", "message": PATH_NOT_FOUND_MESSAGE}, 400
406
693
  # Path points to a project directory
407
694
  # Bug# 233. Path could be a url
408
695
  try:
696
+ if path_is_caller_supplied and not _revalidate_scan_path(path):
697
+ if logger_instance:
698
+ logger_instance.warning("Blocked request for path: %s", path)
699
+ return {"error": "true", "message": PATH_NOT_ALLOWED_MESSAGE}, 403
409
700
  if url or (path and os.path.isdir(path)):
410
701
  if url and not path and not cdxgen_server:
411
702
  return (
@@ -416,7 +707,10 @@ async def run_scan():
416
707
  400,
417
708
  {"Content-Type": "application/json"},
418
709
  )
419
- with tempfile.NamedTemporaryFile(delete=False, suffix=".bom.json") as bfp:
710
+ server_temp_dir = _get_server_temp_dir()
711
+ with tempfile.NamedTemporaryFile(
712
+ delete=False, suffix=".bom.json", dir=server_temp_dir
713
+ ) as bfp:
420
714
  temp_paths.append(bfp.name)
421
715
  if create_bom:
422
716
  bom_status = create_bom(
@@ -432,19 +726,51 @@ async def run_scan():
432
726
  "deep": deep,
433
727
  },
434
728
  )
435
- if bom_status:
729
+ # Use the path the generator says it wrote. The temp file
730
+ # above always exists, so `os.path.exists` proves nothing
731
+ # here and only the returned path is trustworthy.
732
+ if bom_status.success and bom_status.primary:
733
+ # Generators are expected to write inside the temporary
734
+ # directory created for this request. Refusing anything
735
+ # else keeps the response from returning the contents of
736
+ # an unrelated file.
737
+ if not _is_path_within(bom_status.primary, server_temp_dir):
738
+ if logger_instance:
739
+ logger_instance.warning(
740
+ "Discarding BOM written outside the server temporary "
741
+ "directory: %s",
742
+ bom_status.primary,
743
+ )
744
+ return (
745
+ {
746
+ "error": "true",
747
+ "message": "Generated BOM was written to an unexpected location.",
748
+ },
749
+ 500,
750
+ {"Content-Type": "application/json"},
751
+ )
436
752
  if logger_instance:
437
753
  logger_instance.debug(
438
- "BOM file was generated successfully at %s", bfp.name
754
+ "BOM file was generated successfully at %s", bom_status.primary
439
755
  )
440
- bom_file_path = bfp.name
756
+ bom_file_path = bom_status.primary
441
757
  elif logger_instance:
442
758
  logger_instance.debug("Problem generating the SBOM for %s %s", url, path)
443
759
  # Path points to a SBOM file
444
760
  elif path and os.path.exists(path):
445
761
  bom_file_path = path
762
+ # An uploaded BOM was staged in the server's temporary directory.
763
+ bom_is_caller_file = path_is_caller_supplied
446
764
  # Direct purl-based lookups are not supported yet.
447
765
  if bom_file_path is not None:
766
+ # Final check before the BOM is opened and its contents returned.
767
+ # A generated BOM lives in the server's temporary directory, which
768
+ # was checked above; only a BOM file named by the caller is
769
+ # rechecked against the allowlist.
770
+ if bom_is_caller_file and not _revalidate_scan_path(bom_file_path):
771
+ if logger_instance:
772
+ logger_instance.warning("Blocked request for path: %s", bom_file_path)
773
+ return {"error": "true", "message": PATH_NOT_ALLOWED_MESSAGE}, 403
448
774
  pkg_list, _ = get_pkg_list(bom_file_path)
449
775
  if not pkg_list:
450
776
  if logger_instance:
@@ -452,6 +778,9 @@ async def run_scan():
452
778
  else:
453
779
  if logger_instance:
454
780
  logger_instance.debug("Scanning %d oss dependencies for issues", len(pkg_list))
781
+ # Categorize packages by CycloneDX scope (required/optional/excluded)
782
+ # so scope-based prioritization works in server mode just like the CLI.
783
+ scoped_pkgs = get_pkgs_by_scope(pkg_list)
455
784
  bom_data, bom_error = _load_bom_data(bom_file_path, max_bom_file_size)
456
785
  if not bom_data:
457
786
  return (
@@ -465,10 +794,10 @@ async def run_scan():
465
794
  pkg_aliases={},
466
795
  purl_aliases={},
467
796
  suggest_mode=suggest_mode,
468
- scoped_pkgs={},
797
+ scoped_pkgs=scoped_pkgs,
469
798
  no_vuln_table=True,
470
799
  bom_file=bom_file_path,
471
- pkg_list=[],
800
+ pkg_list=pkg_list,
472
801
  direct_purls={},
473
802
  reached_purls={},
474
803
  console=console,
@@ -511,6 +840,7 @@ def run_server(options: ServerOptions):
511
840
  app.config["LOGGER_INSTANCE"] = options.logger
512
841
  app.config.setdefault("API_KEY", os.getenv("DEPSCAN_SERVER_API_KEY"))
513
842
  app.config.setdefault("ALLOW_PRIVATE_URLS", os.getenv("DEPSCAN_SERVER_ALLOW_PRIVATE_URLS"))
843
+ app.config.setdefault("TRUSTED_HOST_NAMES", os.getenv("DEPSCAN_SERVER_TRUSTED_HOST_NAMES"))
514
844
  app.config.setdefault(
515
845
  "ALLOW_UNAUTHENTICATED_BIND",
516
846
  os.getenv("DEPSCAN_SERVER_ALLOW_UNAUTHENTICATED_BIND"),