ds-server-lib 6.2.0__tar.gz → 6.3.1__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.2.0
3
+ Version: 6.3.1
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.2.0"
3
+ version = "6.3.1"
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.2.0
3
+ Version: 6.3.1
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
@@ -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
@@ -15,7 +20,6 @@ from quart import Quart, request
15
20
  from rich.console import Console
16
21
  from server_lib import ServerOptions
17
22
  from vdb.lib import search
18
- from vdb.lib.npm import NpmSource
19
23
 
20
24
  app = Quart(f"dep-scan server ({__name__})", static_folder=None)
21
25
  app.config.from_prefixed_env(prefix="DEPSCAN_SERVER")
@@ -33,21 +37,22 @@ def get_allowed_git_schemes(default_schemes=None):
33
37
 
34
38
  allowed_git_schemes = get_allowed_git_schemes()
35
39
 
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"}
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
+
46
51
 
47
52
  console = Console(
48
53
  log_time=False,
49
54
  log_path=False,
50
- color_system=os.getenv("CONSOLE_COLOR_SCHEME", "256"),
55
+ color_system=_color_system(),
51
56
  tab_size=2,
52
57
  emoji=os.getenv("DISABLE_CONSOLE_EMOJI", "") not in ("true", "1"),
53
58
  )
@@ -57,6 +62,26 @@ MAX_PROJECT_TYPE_LENGTH = 64
57
62
  DEFAULT_MAX_BOM_FILE_SIZE = 100 * 1024 * 1024
58
63
 
59
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-"
60
85
 
61
86
 
62
87
  def _is_truthy(value):
@@ -99,6 +124,59 @@ def _is_allowed_scan_path(path: str, allowed_paths: list[str]) -> bool:
99
124
  return False
100
125
 
101
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
+
102
180
  def _get_config_int(config, *keys: str, default: int, logger=None) -> int:
103
181
  for key in keys:
104
182
  value = config.get(key)
@@ -138,7 +216,10 @@ def _is_private_target(hostname: str | None) -> bool:
138
216
  addr_info = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
139
217
  except socket.gaierror:
140
218
  return True
141
- 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]}
142
223
  return not resolved_ips or any(_is_private_or_local_ip(ip_text) for ip_text in resolved_ips)
143
224
 
144
225
 
@@ -148,6 +229,10 @@ def _validate_remote_url(url: str, allowed_schemes: set[str], allow_private_urls
148
229
  return False, "URL scheme is not allowed."
149
230
  if not parsed.hostname:
150
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."
151
236
  if not allow_private_urls and _is_private_target(parsed.hostname):
152
237
  return False, "URL host resolves to a private, loopback, or otherwise restricted address."
153
238
  return True, ""
@@ -162,6 +247,10 @@ def _parse_project_types(project_type_value: str) -> list[str]:
162
247
  raise ValueError("project type is too long")
163
248
  if not all(ch.isalnum() or ch in ("-", "_", ".", "+") for ch in project_type):
164
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")
165
254
  project_types.append(project_type)
166
255
  if len(project_types) > MAX_PROJECT_TYPES:
167
256
  raise ValueError("too many project types were supplied")
@@ -217,23 +306,131 @@ def _read_upload_with_limit(upload, max_bytes: int):
217
306
  return upload_content, ""
218
307
 
219
308
 
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")
309
+ def _is_cross_site_request(headers) -> bool:
310
+ """Tell whether a browser sent this request on behalf of another site.
225
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()
226
340
 
227
- def audit(project_type, pkg_list):
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.
228
378
  """
229
- Method to audit packages using remote source such as npm advisory
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
+
230
410
 
231
- :param project_type: Project type
232
- :param pkg_list: List of packages
233
- :return: Results
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.
234
417
  """
235
- results = type_audit_map[project_type].bulk_search(app_info=npm_app_info, pkg_list=pkg_list)
236
- return results
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
+
429
+ def _get_request_api_key() -> str | None:
430
+ auth_header = request.headers.get("Authorization", "")
431
+ if auth_header.startswith("Bearer "):
432
+ return auth_header.removeprefix("Bearer ").strip()
433
+ return request.headers.get("X-API-Key")
237
434
 
238
435
 
239
436
  @app.get("/")
@@ -248,6 +445,23 @@ async def index():
248
445
  async def enforce_allowlists():
249
446
  logger_instance = app.config.get("LOGGER_INSTANCE")
250
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
251
465
  if configured_api_key:
252
466
  request_api_key = _get_request_api_key()
253
467
  if not request_api_key or not compare_digest(str(configured_api_key), request_api_key):
@@ -266,20 +480,34 @@ async def enforce_allowlists():
266
480
  logger_instance.warning(f"Blocked request from unauthorized host: {client_host}")
267
481
  return {"error": "true", "message": "Host not allowed"}, 403
268
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
269
504
  allowed_paths = app.config.get("ALLOWED_PATHS")
270
505
  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
506
  if path:
279
507
  if not _is_allowed_scan_path(path, allowed_paths):
280
508
  if logger_instance:
281
509
  logger_instance.warning(f"Blocked request for path: {path}")
282
- return {"error": "true", "message": "Path not allowed"}, 403
510
+ return {"error": "true", "message": PATH_NOT_ALLOWED_MESSAGE}, 403
283
511
 
284
512
 
285
513
  @app.after_request
@@ -323,6 +551,7 @@ async def run_scan():
323
551
  suggest_mode = _is_truthy(q.get("suggest"))
324
552
  fuzzy_search = _is_truthy(q.get("fuzzy_search"))
325
553
  temp_paths = []
554
+ path_is_caller_supplied = True
326
555
  if q.get("url"):
327
556
  url = q.get("url")
328
557
  if q.get("path"):
@@ -348,6 +577,12 @@ async def run_scan():
348
577
  if params.get("profile"):
349
578
  profile = params.get("profile")
350
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
351
586
  if not path and not url and (uploaded_bom_file.get("file", None) is None):
352
587
  return {
353
588
  "error": "true",
@@ -359,8 +594,16 @@ async def run_scan():
359
594
  project_type_list = _parse_project_types(project_type)
360
595
  except ValueError as exc:
361
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
362
604
  cdxgen_server = app.config.get("CDXGEN_SERVER_URL")
363
605
  bom_file_path = None
606
+ bom_is_caller_file = False
364
607
  if uploaded_bom_file.get("file", None) is not None:
365
608
  bom_file = uploaded_bom_file["file"]
366
609
  bom_file_suffix = str(bom_file.filename).rsplit(".", maxsplit=1)[-1]
@@ -415,8 +658,15 @@ async def run_scan():
415
658
  )
416
659
  if logger_instance:
417
660
  logger_instance.debug("Processing uploaded file")
418
- 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
+ )
419
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
420
670
  tmp_bom_file.close()
421
671
  temp_paths.append(path)
422
672
  file_write(path, bom_file_content)
@@ -426,9 +676,27 @@ async def run_scan():
426
676
  )
427
677
  if not is_valid_url:
428
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
429
693
  # Path points to a project directory
430
694
  # Bug# 233. Path could be a url
431
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
432
700
  if url or (path and os.path.isdir(path)):
433
701
  if url and not path and not cdxgen_server:
434
702
  return (
@@ -439,7 +707,10 @@ async def run_scan():
439
707
  400,
440
708
  {"Content-Type": "application/json"},
441
709
  )
442
- 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:
443
714
  temp_paths.append(bfp.name)
444
715
  if create_bom:
445
716
  bom_status = create_bom(
@@ -455,31 +726,61 @@ async def run_scan():
455
726
  "deep": deep,
456
727
  },
457
728
  )
458
- 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
+ )
459
752
  if logger_instance:
460
753
  logger_instance.debug(
461
- "BOM file was generated successfully at %s", bfp.name
754
+ "BOM file was generated successfully at %s", bom_status.primary
462
755
  )
463
- bom_file_path = bfp.name
756
+ bom_file_path = bom_status.primary
464
757
  elif logger_instance:
465
758
  logger_instance.debug("Problem generating the SBOM for %s %s", url, path)
466
759
  # Path points to a SBOM file
467
760
  elif path and os.path.exists(path):
468
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
469
764
  # Direct purl-based lookups are not supported yet.
470
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
471
774
  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
775
  if not pkg_list:
478
776
  if logger_instance:
479
777
  logger_instance.debug("Empty package search attempted!")
480
778
  else:
481
779
  if logger_instance:
482
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)
483
784
  bom_data, bom_error = _load_bom_data(bom_file_path, max_bom_file_size)
484
785
  if not bom_data:
485
786
  return (
@@ -493,10 +794,10 @@ async def run_scan():
493
794
  pkg_aliases={},
494
795
  purl_aliases={},
495
796
  suggest_mode=suggest_mode,
496
- scoped_pkgs={},
797
+ scoped_pkgs=scoped_pkgs,
497
798
  no_vuln_table=True,
498
799
  bom_file=bom_file_path,
499
- pkg_list=[],
800
+ pkg_list=pkg_list,
500
801
  direct_purls={},
501
802
  reached_purls={},
502
803
  console=console,
@@ -539,6 +840,7 @@ def run_server(options: ServerOptions):
539
840
  app.config["LOGGER_INSTANCE"] = options.logger
540
841
  app.config.setdefault("API_KEY", os.getenv("DEPSCAN_SERVER_API_KEY"))
541
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"))
542
844
  app.config.setdefault(
543
845
  "ALLOW_UNAUTHENTICATED_BIND",
544
846
  os.getenv("DEPSCAN_SERVER_ALLOW_UNAUTHENTICATED_BIND"),