ds-server-lib 6.0.0b4__py3-none-any.whl
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.
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ds-server-lib
|
|
3
|
+
Version: 6.0.0b4
|
|
4
|
+
Summary: Server library for owasp depscan
|
|
5
|
+
Author-email: Team AppThreat <cloud@appthreat.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/owasp-dep-scan/dep-scan
|
|
8
|
+
Project-URL: Bug-Tracker, https://github.com/owasp-dep-scan/dep-scan/issues
|
|
9
|
+
Project-URL: Funding, https://owasp.org/donate/?reponame=www-project-dep-scan&title=OWASP+depscan
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: System Administrators
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Security
|
|
19
|
+
Classifier: Topic :: Utilities
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: appthreat-vulnerability-db>=6.4.4
|
|
23
|
+
Requires-Dist: custom-json-diff>=2.1.6
|
|
24
|
+
Requires-Dist: quart>=0.20.0
|
|
25
|
+
Requires-Dist: rich>=13.9.4
|
|
26
|
+
Requires-Dist: ds-analysis-lib
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=8.3.4; extra == "dev"
|
|
29
|
+
Requires-Dist: pytest-asyncio>=1.2.0; extra == "dev"
|
|
30
|
+
Requires-Dist: pytest-cov>=6.0.0; extra == "dev"
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
server_lib/__init__.py,sha256=ws_ZqTtj9Gz593MUBx4bS3xPaFsPCZcq5Be4Aee-_XE,685
|
|
2
|
+
server_lib/simple.py,sha256=mtP2HT927EqqkhHfb7YWryRLzh2sX8S6WRJn-7MCOZE,11380
|
|
3
|
+
ds_server_lib-6.0.0b4.dist-info/METADATA,sha256=Xev5RiLR16P9JVyUlK6O2UEa5BISW8KWQ3VpKdFRWQM,1290
|
|
4
|
+
ds_server_lib-6.0.0b4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
5
|
+
ds_server_lib-6.0.0b4.dist-info/top_level.txt,sha256=ckgyBsWNEmm2AP_zSy5Qb0ktxCsRt9jSiPwC897x9Po,11
|
|
6
|
+
ds_server_lib-6.0.0b4.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
server_lib
|
server_lib/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Callable, Dict, List, Optional
|
|
3
|
+
from logging import Logger
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class ServerOptions:
|
|
9
|
+
server_host: str = "127.0.0.1"
|
|
10
|
+
server_port: int = 7070
|
|
11
|
+
cdxgen_server: Optional[str] = None
|
|
12
|
+
allowed_hosts: Optional[List[str]] = None
|
|
13
|
+
allowed_paths: Optional[List[str]] = None
|
|
14
|
+
console: Optional[Console] = None
|
|
15
|
+
logger: Optional[Logger] = None
|
|
16
|
+
ca_certs: Optional[str] = None
|
|
17
|
+
certfile: Optional[str] = None
|
|
18
|
+
keyfile: Optional[str] = None
|
|
19
|
+
debug: bool = False
|
|
20
|
+
max_content_length: int = 100 * 1024 * 1024 # 100MB
|
|
21
|
+
# Hack
|
|
22
|
+
create_bom: Optional[Callable] = None
|
server_lib/simple.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
from quart import request, Quart
|
|
4
|
+
import tempfile
|
|
5
|
+
|
|
6
|
+
from analysis_lib import VdrAnalysisKV
|
|
7
|
+
from analysis_lib.vdr import VDRAnalyzer
|
|
8
|
+
from server_lib import ServerOptions
|
|
9
|
+
|
|
10
|
+
app = Quart(f"dep-scan server ({__name__})", static_folder=None)
|
|
11
|
+
app.config.from_prefixed_env(prefix="DEPSCAN_SERVER")
|
|
12
|
+
app.config["PROVIDE_AUTOMATIC_OPTIONS"] = True
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_allowed_git_schemes(default_schemes=None):
|
|
16
|
+
if default_schemes is None:
|
|
17
|
+
default_schemes = {"http", "https", "git", "git+http", "git+https"}
|
|
18
|
+
env_var_value = os.getenv("DEPSCAN_SERVER_ALLOWED_GIT_SCHEMES")
|
|
19
|
+
if env_var_value is not None:
|
|
20
|
+
return {scheme.strip() for scheme in env_var_value.split(",") if scheme.strip()}
|
|
21
|
+
return default_schemes
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
allowed_git_schemes = get_allowed_git_schemes()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.get("/")
|
|
28
|
+
async def index():
|
|
29
|
+
"""
|
|
30
|
+
:return: An empty dictionary
|
|
31
|
+
"""
|
|
32
|
+
return {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app.before_request
|
|
36
|
+
async def enforce_allowlists():
|
|
37
|
+
LOG = app.config.get("LOGGER_INSTANCE")
|
|
38
|
+
is_testing = bool(os.getenv("PYTEST_CURRENT_TEST"))
|
|
39
|
+
if is_testing:
|
|
40
|
+
client_host = request.headers.get("X-Test-Remote-Addr")
|
|
41
|
+
else:
|
|
42
|
+
client_host = request.remote_addr
|
|
43
|
+
allowed_hosts = app.config.get("ALLOWED_HOSTS")
|
|
44
|
+
if allowed_hosts is not None:
|
|
45
|
+
if not client_host or client_host not in allowed_hosts:
|
|
46
|
+
if LOG:
|
|
47
|
+
LOG.warning(f"Blocked request from unauthorized host: {client_host}")
|
|
48
|
+
return {"error": "true", "message": "Host not allowed"}, 403
|
|
49
|
+
if request.path == "/scan":
|
|
50
|
+
allowed_paths = app.config.get("ALLOWED_PATHS")
|
|
51
|
+
if allowed_paths is not None:
|
|
52
|
+
path = None
|
|
53
|
+
if request.args.get("path"):
|
|
54
|
+
path = request.args.get("path")
|
|
55
|
+
elif request.method == "POST":
|
|
56
|
+
json_data = await request.get_json(silent=True)
|
|
57
|
+
if json_data and "path" in json_data:
|
|
58
|
+
path = json_data["path"]
|
|
59
|
+
if path:
|
|
60
|
+
try:
|
|
61
|
+
real_path = os.path.realpath(path)
|
|
62
|
+
if not any(
|
|
63
|
+
real_path.startswith(os.path.realpath(a)) for a in allowed_paths
|
|
64
|
+
):
|
|
65
|
+
if LOG:
|
|
66
|
+
LOG.warning(f"Blocked request for path: {path}")
|
|
67
|
+
return {"error": "true", "message": "Path not allowed"}, 403
|
|
68
|
+
except (OSError, ValueError):
|
|
69
|
+
return {"error": "true", "message": "Invalid path"}, 403
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@app.after_request
|
|
73
|
+
async def add_security_headers(response):
|
|
74
|
+
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
75
|
+
response.headers["X-Frame-Options"] = "SAMEORIGIN"
|
|
76
|
+
response.headers["X-XSS-Protection"] = "1; mode=block"
|
|
77
|
+
response.headers["Strict-Transport-Security"] = (
|
|
78
|
+
"max-age=31536000; includeSubDomains"
|
|
79
|
+
)
|
|
80
|
+
return response
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.route("/scan", methods=["GET", "POST"])
|
|
84
|
+
async def run_scan():
|
|
85
|
+
"""
|
|
86
|
+
:return: A JSON response containing the SBOM file path and a list of
|
|
87
|
+
vulnerabilities found in the scanned packages
|
|
88
|
+
"""
|
|
89
|
+
LOG = app.config.get("LOGGER_INSTANCE")
|
|
90
|
+
q = request.args
|
|
91
|
+
params = await request.get_json()
|
|
92
|
+
uploaded_bom_file = await request.files
|
|
93
|
+
create_bom = app.config.get("create_bom")
|
|
94
|
+
url = None
|
|
95
|
+
path = None
|
|
96
|
+
multi_project = None
|
|
97
|
+
project_type = None
|
|
98
|
+
results = []
|
|
99
|
+
profile = "generic"
|
|
100
|
+
deep = False
|
|
101
|
+
suggest_mode = True if q.get("suggest") in ("true", "1") else False
|
|
102
|
+
fuzzy_search = True if q.get("fuzzy_search") in ("true", "1") else False
|
|
103
|
+
if q.get("url"):
|
|
104
|
+
url = q.get("url")
|
|
105
|
+
if q.get("path"):
|
|
106
|
+
path = q.get("path")
|
|
107
|
+
if q.get("multiProject"):
|
|
108
|
+
multi_project = q.get("multiProject", "").lower() in ("true", "1")
|
|
109
|
+
if q.get("deep"):
|
|
110
|
+
deep = q.get("deep", "").lower() in ("true", "1")
|
|
111
|
+
if q.get("type"):
|
|
112
|
+
project_type = q.get("type")
|
|
113
|
+
if q.get("profile"):
|
|
114
|
+
profile = q.get("profile")
|
|
115
|
+
if params is not None:
|
|
116
|
+
if not url and params.get("url"):
|
|
117
|
+
url = params.get("url")
|
|
118
|
+
if not path and params.get("path"):
|
|
119
|
+
path = params.get("path")
|
|
120
|
+
if not multi_project and params.get("multiProject"):
|
|
121
|
+
multi_project = params.get("multiProject", "").lower() in (
|
|
122
|
+
"true",
|
|
123
|
+
"1",
|
|
124
|
+
)
|
|
125
|
+
if not deep and params.get("deep"):
|
|
126
|
+
deep = params.get("deep", "").lower() in (
|
|
127
|
+
"true",
|
|
128
|
+
"1",
|
|
129
|
+
)
|
|
130
|
+
if not project_type and params.get("type"):
|
|
131
|
+
project_type = params.get("type")
|
|
132
|
+
if not profile and params.get("profile"):
|
|
133
|
+
profile = params.get("profile")
|
|
134
|
+
|
|
135
|
+
if not path and not url and (uploaded_bom_file.get("file", None) is None):
|
|
136
|
+
return {
|
|
137
|
+
"error": "true",
|
|
138
|
+
"message": "path or url or a bom file upload is required",
|
|
139
|
+
}, 400
|
|
140
|
+
if not project_type:
|
|
141
|
+
return {"error": "true", "message": "project type is required"}, 400
|
|
142
|
+
cdxgen_server = app.config.get("CDXGEN_SERVER_URL")
|
|
143
|
+
bom_file_path = None
|
|
144
|
+
tmp_bom_file = None
|
|
145
|
+
if uploaded_bom_file.get("file", None) is not None:
|
|
146
|
+
bom_file = uploaded_bom_file["file"]
|
|
147
|
+
bom_file_suffix = str(bom_file.filename).rsplit(".", maxsplit=1)[-1]
|
|
148
|
+
if bom_file_suffix not in (".json", ".cdx", ".bom"):
|
|
149
|
+
return (
|
|
150
|
+
{
|
|
151
|
+
"error": "true",
|
|
152
|
+
"message": "The uploaded file must be a valid JSON.",
|
|
153
|
+
},
|
|
154
|
+
400,
|
|
155
|
+
{"Content-Type": "application/json"},
|
|
156
|
+
)
|
|
157
|
+
bom_file_content = bom_file.read().decode("utf-8")
|
|
158
|
+
try:
|
|
159
|
+
_ = json.loads(bom_file_content)
|
|
160
|
+
if (
|
|
161
|
+
not isinstance(bom_data, dict)
|
|
162
|
+
or bom_data.get("bomFormat") != "CycloneDX"
|
|
163
|
+
):
|
|
164
|
+
return {
|
|
165
|
+
"error": "true",
|
|
166
|
+
"message": "Uploaded file is not a valid CycloneDX BOM.",
|
|
167
|
+
}, 400
|
|
168
|
+
except (json.JSONDecodeError, KeyError):
|
|
169
|
+
return (
|
|
170
|
+
{
|
|
171
|
+
"error": "true",
|
|
172
|
+
"message": "The uploaded file must be a valid JSON.",
|
|
173
|
+
},
|
|
174
|
+
400,
|
|
175
|
+
{"Content-Type": "application/json"},
|
|
176
|
+
)
|
|
177
|
+
if LOG:
|
|
178
|
+
LOG.debug("Processing uploaded file")
|
|
179
|
+
tmp_bom_file = tempfile.NamedTemporaryFile(
|
|
180
|
+
delete=False, suffix=f".bom.{bom_file_suffix}"
|
|
181
|
+
)
|
|
182
|
+
path = tmp_bom_file.name
|
|
183
|
+
file_write(path, bom_file_content)
|
|
184
|
+
if url:
|
|
185
|
+
parsed = urlparse(url)
|
|
186
|
+
if not parsed.scheme or parsed.scheme not in allowed_git_schemes:
|
|
187
|
+
return {"error": "true", "message": "URL scheme is not allowed."}, 400
|
|
188
|
+
# Path points to a project directory
|
|
189
|
+
# Bug# 233. Path could be a url
|
|
190
|
+
if url or (path and os.path.isdir(path)):
|
|
191
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=".bom.json") as bfp:
|
|
192
|
+
project_type_list = project_type.split(",")
|
|
193
|
+
if create_bom:
|
|
194
|
+
bom_status = create_bom(
|
|
195
|
+
bfp.name,
|
|
196
|
+
path,
|
|
197
|
+
{
|
|
198
|
+
"url": url,
|
|
199
|
+
"path": path,
|
|
200
|
+
"project_type": project_type_list,
|
|
201
|
+
"multiProject": multi_project,
|
|
202
|
+
"cdxgen_server": cdxgen_server,
|
|
203
|
+
"profile": profile,
|
|
204
|
+
"deep": deep,
|
|
205
|
+
},
|
|
206
|
+
)
|
|
207
|
+
if bom_status:
|
|
208
|
+
if LOG:
|
|
209
|
+
LOG.debug("BOM file was generated successfully at %s", bfp.name)
|
|
210
|
+
bom_file_path = bfp.name
|
|
211
|
+
|
|
212
|
+
# Path points to a SBOM file
|
|
213
|
+
else:
|
|
214
|
+
if os.path.exists(path):
|
|
215
|
+
bom_file_path = path
|
|
216
|
+
# Direct purl-based lookups are not supported yet.
|
|
217
|
+
if bom_file_path is not None:
|
|
218
|
+
pkg_list, _ = get_pkg_list(bom_file_path)
|
|
219
|
+
# Here we are assuming there will be only one type
|
|
220
|
+
if project_type in type_audit_map:
|
|
221
|
+
audit_results = audit(project_type, pkg_list)
|
|
222
|
+
if audit_results:
|
|
223
|
+
results = results + audit_results
|
|
224
|
+
if not pkg_list:
|
|
225
|
+
if LOG:
|
|
226
|
+
LOG.debug("Empty package search attempted!")
|
|
227
|
+
else:
|
|
228
|
+
if LOG:
|
|
229
|
+
LOG.debug("Scanning %d oss dependencies for issues", len(pkg_list))
|
|
230
|
+
bom_data = json_load(bom_file_path)
|
|
231
|
+
if not bom_data:
|
|
232
|
+
cleanup_temp(tmp_bom_file)
|
|
233
|
+
return (
|
|
234
|
+
{
|
|
235
|
+
"error": "true",
|
|
236
|
+
"message": "Unable to generate SBOM. Check your input path or url.",
|
|
237
|
+
},
|
|
238
|
+
400,
|
|
239
|
+
{"Content-Type": "application/json"},
|
|
240
|
+
)
|
|
241
|
+
options = VdrAnalysisKV(
|
|
242
|
+
project_type,
|
|
243
|
+
results,
|
|
244
|
+
pkg_aliases={},
|
|
245
|
+
purl_aliases={},
|
|
246
|
+
suggest_mode=suggest_mode,
|
|
247
|
+
scoped_pkgs={},
|
|
248
|
+
no_vuln_table=True,
|
|
249
|
+
bom_file=bom_file_path,
|
|
250
|
+
pkg_list=[],
|
|
251
|
+
direct_purls={},
|
|
252
|
+
reached_purls={},
|
|
253
|
+
console=console,
|
|
254
|
+
logger=LOG,
|
|
255
|
+
fuzzy_search=fuzzy_search,
|
|
256
|
+
)
|
|
257
|
+
vdr_result = VDRAnalyzer(vdr_options=options).process()
|
|
258
|
+
if vdr_result.success:
|
|
259
|
+
pkg_vulnerabilities = vdr_result.pkg_vulnerabilities
|
|
260
|
+
if pkg_vulnerabilities:
|
|
261
|
+
bom_data["vulnerabilities"] = pkg_vulnerabilities
|
|
262
|
+
cleanup_temp(tmp_bom_file)
|
|
263
|
+
return json.dumps(bom_data), 200, {"Content-Type": "application/json"}
|
|
264
|
+
cleanup_temp(tmp_bom_file)
|
|
265
|
+
return (
|
|
266
|
+
{
|
|
267
|
+
"error": "true",
|
|
268
|
+
"message": "Unable to generate SBOM. Check your input path or url.",
|
|
269
|
+
},
|
|
270
|
+
500,
|
|
271
|
+
{"Content-Type": "application/json"},
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def cleanup_temp(tmp_bom_file):
|
|
276
|
+
if tmp_bom_file:
|
|
277
|
+
os.remove(tmp_bom_file)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def run_server(options: ServerOptions):
|
|
281
|
+
app.config["CDXGEN_SERVER_URL"] = options.cdxgen_server
|
|
282
|
+
app.config["LOGGER_INSTANCE"] = options.logger
|
|
283
|
+
if options.allowed_hosts:
|
|
284
|
+
app.config["ALLOWED_HOSTS"] = [h.strip() for h in options.allowed_hosts if h]
|
|
285
|
+
if options.allowed_paths:
|
|
286
|
+
app.config["ALLOWED_PATHS"] = [
|
|
287
|
+
os.path.realpath(p) for p in options.allowed_paths if p
|
|
288
|
+
]
|
|
289
|
+
if options.max_content_length:
|
|
290
|
+
app.config["MAX_CONTENT_LENGTH"] = options.max_content_length
|
|
291
|
+
# Dirty hack to get access to the create_bom function
|
|
292
|
+
if options.create_bom:
|
|
293
|
+
app.config["create_bom"] = create_bom
|
|
294
|
+
logger = options.logger
|
|
295
|
+
if logger:
|
|
296
|
+
logger.info(
|
|
297
|
+
f"dep-scan server running on {options.server_host}:{options.server_port}"
|
|
298
|
+
)
|
|
299
|
+
if options.server_host not in (
|
|
300
|
+
"127.0.0.1",
|
|
301
|
+
"0000:0000:0000:0000:0000:0000:0000:0001",
|
|
302
|
+
"0:0:0:0:0:0:0:1",
|
|
303
|
+
"::1",
|
|
304
|
+
):
|
|
305
|
+
logger.warning(
|
|
306
|
+
"Server listening on non-local host without built-in authentication."
|
|
307
|
+
)
|
|
308
|
+
app.run(
|
|
309
|
+
host=options.server_host,
|
|
310
|
+
port=options.server_port,
|
|
311
|
+
debug=options.debug,
|
|
312
|
+
use_reloader=False,
|
|
313
|
+
ca_certs=options.ca_certs,
|
|
314
|
+
certfile=options.certfile,
|
|
315
|
+
keyfile=options.keyfile,
|
|
316
|
+
)
|