ds-server-lib 6.3.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.
- {ds_server_lib-6.3.0 → ds_server_lib-6.3.1}/PKG-INFO +1 -1
- {ds_server_lib-6.3.0 → ds_server_lib-6.3.1}/pyproject.toml +1 -1
- {ds_server_lib-6.3.0 → ds_server_lib-6.3.1}/src/ds_server_lib.egg-info/PKG-INFO +1 -1
- {ds_server_lib-6.3.0 → ds_server_lib-6.3.1}/src/server_lib/__init__.py +4 -1
- {ds_server_lib-6.3.0 → ds_server_lib-6.3.1}/src/server_lib/simple.py +348 -18
- ds_server_lib-6.3.1/tests/test_simple.py +1282 -0
- ds_server_lib-6.3.0/tests/test_simple.py +0 -429
- {ds_server_lib-6.3.0 → ds_server_lib-6.3.1}/README.md +0 -0
- {ds_server_lib-6.3.0 → ds_server_lib-6.3.1}/setup.cfg +0 -0
- {ds_server_lib-6.3.0 → ds_server_lib-6.3.1}/src/ds_server_lib.egg-info/SOURCES.txt +0 -0
- {ds_server_lib-6.3.0 → ds_server_lib-6.3.1}/src/ds_server_lib.egg-info/dependency_links.txt +0 -0
- {ds_server_lib-6.3.0 → ds_server_lib-6.3.1}/src/ds_server_lib.egg-info/requires.txt +0 -0
- {ds_server_lib-6.3.0 → ds_server_lib-6.3.1}/src/ds_server_lib.egg-info/top_level.txt +0 -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
|
-
|
|
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
|
|
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=
|
|
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
|
-
|
|
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":
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
754
|
+
"BOM file was generated successfully at %s", bom_status.primary
|
|
439
755
|
)
|
|
440
|
-
bom_file_path =
|
|
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"),
|