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.
- {ds_server_lib-6.2.0 → ds_server_lib-6.3.1}/PKG-INFO +3 -2
- {ds_server_lib-6.2.0 → ds_server_lib-6.3.1}/pyproject.toml +3 -2
- {ds_server_lib-6.2.0 → ds_server_lib-6.3.1}/src/ds_server_lib.egg-info/PKG-INFO +3 -2
- {ds_server_lib-6.2.0 → ds_server_lib-6.3.1}/src/ds_server_lib.egg-info/requires.txt +1 -1
- {ds_server_lib-6.2.0 → ds_server_lib-6.3.1}/src/server_lib/__init__.py +4 -1
- {ds_server_lib-6.2.0 → ds_server_lib-6.3.1}/src/server_lib/simple.py +348 -46
- ds_server_lib-6.3.1/tests/test_simple.py +1282 -0
- ds_server_lib-6.2.0/tests/test_simple.py +0 -429
- {ds_server_lib-6.2.0 → ds_server_lib-6.3.1}/README.md +0 -0
- {ds_server_lib-6.2.0 → ds_server_lib-6.3.1}/setup.cfg +0 -0
- {ds_server_lib-6.2.0 → ds_server_lib-6.3.1}/src/ds_server_lib.egg-info/SOURCES.txt +0 -0
- {ds_server_lib-6.2.0 → ds_server_lib-6.3.1}/src/ds_server_lib.egg-info/dependency_links.txt +0 -0
- {ds_server_lib-6.2.0 → ds_server_lib-6.3.1}/src/ds_server_lib.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ds-server-lib
|
|
3
|
-
Version: 6.
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
@@ -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
|
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
236
|
-
|
|
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":
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
754
|
+
"BOM file was generated successfully at %s", bom_status.primary
|
|
462
755
|
)
|
|
463
|
-
bom_file_path =
|
|
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"),
|