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