bdba-client 0.13.0__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.
- bdba/__init__.py +0 -0
- bdba/client.py +669 -0
- bdba/limits.py +24 -0
- bdba/model.py +288 -0
- bdba/util.py +22 -0
- bdba_client-0.13.0.dist-info/LICENSE +73 -0
- bdba_client-0.13.0.dist-info/METADATA +10 -0
- bdba_client-0.13.0.dist-info/RECORD +10 -0
- bdba_client-0.13.0.dist-info/WHEEL +5 -0
- bdba_client-0.13.0.dist-info/top_level.txt +1 -0
bdba/__init__.py
ADDED
|
File without changes
|
bdba/client.py
ADDED
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Gardener contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
import collections.abc
|
|
6
|
+
import datetime
|
|
7
|
+
import enum
|
|
8
|
+
import functools
|
|
9
|
+
import logging
|
|
10
|
+
import time
|
|
11
|
+
import traceback
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib3.util.retry
|
|
14
|
+
|
|
15
|
+
import cachecontrol
|
|
16
|
+
import dacite
|
|
17
|
+
import dateutil.parser
|
|
18
|
+
import requests
|
|
19
|
+
|
|
20
|
+
import bdba.limits
|
|
21
|
+
import bdba.model as bm
|
|
22
|
+
import bdba.util as bu
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def kebab_to_snake_case_keys(d: dict[str, dict | list | str | int]) -> dict:
|
|
29
|
+
"""
|
|
30
|
+
Highly opinionated function to convert the BDBA analysis result so that it can be processed by
|
|
31
|
+
our BDBA model classes. In that, it converts kebab-cased keys recursively into snake_case (as
|
|
32
|
+
expected by our model classes).
|
|
33
|
+
"""
|
|
34
|
+
result = {}
|
|
35
|
+
|
|
36
|
+
for key, value in d.items():
|
|
37
|
+
if isinstance(value, dict):
|
|
38
|
+
value = kebab_to_snake_case_keys(value)
|
|
39
|
+
elif isinstance(value, list):
|
|
40
|
+
value = [kebab_to_snake_case_keys(v) if isinstance(v, dict) else v for v in value]
|
|
41
|
+
|
|
42
|
+
if not isinstance(key, str):
|
|
43
|
+
raise TypeError(f'{key=} is expected to be of type "str", but is type "{type(key)}"')
|
|
44
|
+
|
|
45
|
+
result[key.replace('-', '_')] = value
|
|
46
|
+
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class BDBAApiRoutes:
|
|
51
|
+
"""
|
|
52
|
+
calculates API routes (URLs) for a subset of the URL endpoints exposed by
|
|
53
|
+
"BDBA"
|
|
54
|
+
|
|
55
|
+
Not intended to be instantiated by users of this module
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(self, base_url):
|
|
59
|
+
if base_url is None:
|
|
60
|
+
raise ValueError(f'{base_url=} must not be None')
|
|
61
|
+
self._base_url = base_url
|
|
62
|
+
self._api_url = functools.partial(self._url, 'api')
|
|
63
|
+
self._rest_url = functools.partial(self._url, 'rest')
|
|
64
|
+
|
|
65
|
+
def _url(self, *parts):
|
|
66
|
+
return bu.urljoin(self._base_url, *parts)
|
|
67
|
+
|
|
68
|
+
def apps(self, group_id=None, custom_attribs={}):
|
|
69
|
+
url = self._api_url('apps')
|
|
70
|
+
if group_id is not None:
|
|
71
|
+
url = bu.urljoin(url, str(group_id))
|
|
72
|
+
|
|
73
|
+
search_query = ' '.join(['meta:' + str(k) + '=' + str(v) for k, v in custom_attribs.items()])
|
|
74
|
+
if search_query:
|
|
75
|
+
url += '?' + urllib.parse.urlencode({'q': search_query})
|
|
76
|
+
|
|
77
|
+
return url
|
|
78
|
+
|
|
79
|
+
def login(self):
|
|
80
|
+
return self._url('login') + '/'
|
|
81
|
+
|
|
82
|
+
def pdf_report(self, product_id: int):
|
|
83
|
+
return self._url('products', str(product_id), 'pdf-report')
|
|
84
|
+
|
|
85
|
+
def groups(self):
|
|
86
|
+
return self._api_url('groups')
|
|
87
|
+
|
|
88
|
+
def upload(self, file_name):
|
|
89
|
+
return self._api_url('upload', urllib.parse.quote_plus(file_name))
|
|
90
|
+
|
|
91
|
+
def product(self, product_id: int):
|
|
92
|
+
return self._api_url('product', str(product_id))
|
|
93
|
+
|
|
94
|
+
def product_custom_data(self, product_id: int):
|
|
95
|
+
return self._api_url('product', str(product_id), 'custom-data')
|
|
96
|
+
|
|
97
|
+
def rescan(self, product_id):
|
|
98
|
+
return self._api_url('product', str(product_id), 'rescan')
|
|
99
|
+
|
|
100
|
+
def triage(self):
|
|
101
|
+
return self._api_url('triage', 'vulnerability/')
|
|
102
|
+
|
|
103
|
+
def version_override(self):
|
|
104
|
+
return self._api_url('versionoverride/')
|
|
105
|
+
|
|
106
|
+
def api_key(self):
|
|
107
|
+
return self._api_url('key/')
|
|
108
|
+
|
|
109
|
+
def export_product(
|
|
110
|
+
self,
|
|
111
|
+
product_id: int | str,
|
|
112
|
+
sbom_format: bm.BdbaSbomFormat = bm.BdbaSbomFormat.BDIO,
|
|
113
|
+
) -> str:
|
|
114
|
+
url = self._api_url('product', str(product_id), f'?format={sbom_format}')
|
|
115
|
+
if bm.BdbaSbomFormat(sbom_format) is bm.BdbaSbomFormat.CYCLONEDX:
|
|
116
|
+
url = f'{url.rstrip("/")}/json'
|
|
117
|
+
return url
|
|
118
|
+
|
|
119
|
+
# ---- "rest" routes (undocumented API)
|
|
120
|
+
|
|
121
|
+
def scans(self, product_id: int):
|
|
122
|
+
return self._rest_url('scans', str(product_id)) + '/'
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def check_http_code(function):
|
|
126
|
+
"""
|
|
127
|
+
a decorator that will check on `requests.Response` instances returned by HTTP requests
|
|
128
|
+
issued with `requests`. In case the response code indicates an error, a warning is logged
|
|
129
|
+
and a `requests.HTTPError` is raised.
|
|
130
|
+
|
|
131
|
+
@param: the function to wrap; should be `requests.<http-verb>`, e.g. requests.get
|
|
132
|
+
@raises: `requests.HTTPError` if response's status code indicates an error
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
@functools.wraps(function)
|
|
136
|
+
def http_checker(*args, **kwargs):
|
|
137
|
+
result = function(*args, **kwargs)
|
|
138
|
+
if not result.ok:
|
|
139
|
+
url = kwargs.get('url', None)
|
|
140
|
+
logger.warning(f'{result.status_code=} - {result.content=}: {url=}')
|
|
141
|
+
result.raise_for_status()
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
return http_checker
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class LoggingRetry(urllib3.util.retry.Retry):
|
|
148
|
+
def __init__(
|
|
149
|
+
self,
|
|
150
|
+
**kwargs,
|
|
151
|
+
):
|
|
152
|
+
defaults = dict(
|
|
153
|
+
total=3,
|
|
154
|
+
connect=3,
|
|
155
|
+
read=3,
|
|
156
|
+
status=3,
|
|
157
|
+
redirect=False,
|
|
158
|
+
status_forcelist=(429, 500, 502, 503, 504),
|
|
159
|
+
raise_on_status=False,
|
|
160
|
+
respect_retry_after_header=True,
|
|
161
|
+
backoff_factor=1.0,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
super().__init__(**(defaults | kwargs))
|
|
165
|
+
|
|
166
|
+
def increment(
|
|
167
|
+
self,
|
|
168
|
+
method=None,
|
|
169
|
+
url=None,
|
|
170
|
+
response=None,
|
|
171
|
+
error=None,
|
|
172
|
+
_pool=None,
|
|
173
|
+
_stacktrace=None,
|
|
174
|
+
):
|
|
175
|
+
# super().increment will either raise an exception indicating that no retry is to
|
|
176
|
+
# be performed or return a new, modified instance of this class
|
|
177
|
+
retry = super().increment(method, url, response, error, _pool, _stacktrace)
|
|
178
|
+
# Use the Retry history to determine the number of retries.
|
|
179
|
+
num_retries = len(self.history) if self.history else 0
|
|
180
|
+
logger.warning(
|
|
181
|
+
f'{method=} {url=} returned {response=} {error=} {num_retries=} - trying again',
|
|
182
|
+
)
|
|
183
|
+
return retry
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _mount_default_adapter(
|
|
187
|
+
session: requests.Session,
|
|
188
|
+
connection_pool_cache_size=32, # requests-library default
|
|
189
|
+
max_pool_size=32, # requests-library default
|
|
190
|
+
retry_cfg: urllib3.util.retry.Retry = LoggingRetry(),
|
|
191
|
+
):
|
|
192
|
+
http_adapter = cachecontrol.CacheControlAdapter(
|
|
193
|
+
max_retries=retry_cfg,
|
|
194
|
+
pool_connections=connection_pool_cache_size,
|
|
195
|
+
pool_maxsize=max_pool_size,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
session.mount('http://', http_adapter)
|
|
199
|
+
session.mount('https://', http_adapter)
|
|
200
|
+
|
|
201
|
+
return session
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class BDBAApi:
|
|
205
|
+
def __init__(
|
|
206
|
+
self,
|
|
207
|
+
api_routes: BDBAApiRoutes,
|
|
208
|
+
token: str,
|
|
209
|
+
tls_verify: bool = True,
|
|
210
|
+
):
|
|
211
|
+
self._routes = api_routes
|
|
212
|
+
self._token = token
|
|
213
|
+
self._tls_verify = tls_verify
|
|
214
|
+
self._session = requests.Session()
|
|
215
|
+
_mount_default_adapter(
|
|
216
|
+
session=self._session,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
@check_http_code
|
|
220
|
+
def _request(self, method, *args, **kwargs):
|
|
221
|
+
if 'headers' in kwargs:
|
|
222
|
+
headers = kwargs['headers']
|
|
223
|
+
del kwargs['headers']
|
|
224
|
+
else:
|
|
225
|
+
headers = {}
|
|
226
|
+
|
|
227
|
+
headers['Authorization'] = f'Bearer {self._token}'
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
timeout = kwargs.pop('timeout')
|
|
231
|
+
except KeyError:
|
|
232
|
+
timeout = (4, 121)
|
|
233
|
+
|
|
234
|
+
return functools.partial(
|
|
235
|
+
method,
|
|
236
|
+
verify=self._tls_verify,
|
|
237
|
+
cookies=None,
|
|
238
|
+
headers=headers,
|
|
239
|
+
timeout=timeout,
|
|
240
|
+
)(*args, **kwargs)
|
|
241
|
+
|
|
242
|
+
@check_http_code
|
|
243
|
+
def _get(self, *args, **kwargs):
|
|
244
|
+
return self._request(self._session.get, *args, **kwargs)
|
|
245
|
+
|
|
246
|
+
@check_http_code
|
|
247
|
+
def _post(self, *args, **kwargs):
|
|
248
|
+
return self._request(self._session.post, *args, **kwargs)
|
|
249
|
+
|
|
250
|
+
@check_http_code
|
|
251
|
+
def _put(self, *args, **kwargs):
|
|
252
|
+
return self._request(self._session.put, *args, **kwargs)
|
|
253
|
+
|
|
254
|
+
@check_http_code
|
|
255
|
+
def _delete(self, *args, **kwargs):
|
|
256
|
+
return self._request(self._session.delete, *args, **kwargs)
|
|
257
|
+
|
|
258
|
+
@check_http_code
|
|
259
|
+
def _patch(self, *args, **kwargs):
|
|
260
|
+
return self._request(self._session.patch, *args, **kwargs)
|
|
261
|
+
|
|
262
|
+
def _metadata_dict(self, custom_attributes):
|
|
263
|
+
"""
|
|
264
|
+
replaces "invalid" underscore characters (setting metadata fails silently if
|
|
265
|
+
those are present). Note: dash characters are implcitly converted to underscore
|
|
266
|
+
by BDBAA. Also, translates `None` to an empty string as header fields with
|
|
267
|
+
`None` are going to be silently ignored while an empty string is used to remove
|
|
268
|
+
a metadata attribute
|
|
269
|
+
"""
|
|
270
|
+
return {
|
|
271
|
+
'META-' + str(k).replace('_', '-'): v if v is not None else ''
|
|
272
|
+
for k, v in custom_attributes.items()
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
def upload(
|
|
276
|
+
self,
|
|
277
|
+
application_name: str,
|
|
278
|
+
group_id: str,
|
|
279
|
+
data: collections.abc.Generator[bytes, None, None],
|
|
280
|
+
replace_id: int = None,
|
|
281
|
+
custom_attribs={},
|
|
282
|
+
) -> bm.Result:
|
|
283
|
+
if bdba.limits.fits(application_name, limit=bdba.limits.file_name):
|
|
284
|
+
name = application_name
|
|
285
|
+
else:
|
|
286
|
+
# BDBA raises HTTP 500 error in case the _file_ (!= app) name exceeds 241 characters
|
|
287
|
+
name = bdba.limits.trim(application_name, limit=bdba.limits.file_name)
|
|
288
|
+
logger.warning(
|
|
289
|
+
f'{application_name=} exceeds character limit of {bdba.limits.file_name} and was '
|
|
290
|
+
f'truncated to {name}',
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
url = self._routes.upload(file_name=name)
|
|
294
|
+
|
|
295
|
+
headers = {'Group': str(group_id)}
|
|
296
|
+
if replace_id:
|
|
297
|
+
headers['Replace'] = str(replace_id)
|
|
298
|
+
headers.update(self._metadata_dict(custom_attribs))
|
|
299
|
+
|
|
300
|
+
result = (
|
|
301
|
+
self._put(
|
|
302
|
+
url=url,
|
|
303
|
+
headers=headers,
|
|
304
|
+
data=data,
|
|
305
|
+
)
|
|
306
|
+
.json()
|
|
307
|
+
.get('results', {})
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
return dacite.from_dict(
|
|
311
|
+
data_class=bm.Result,
|
|
312
|
+
data=kebab_to_snake_case_keys(result),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
def delete_product(self, product_id: int):
|
|
316
|
+
url = self._routes.product(product_id=product_id)
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
self._delete(
|
|
320
|
+
url=url,
|
|
321
|
+
)
|
|
322
|
+
except requests.exceptions.HTTPError as e:
|
|
323
|
+
if e.response.status_code == 404:
|
|
324
|
+
# if the http status is 404 it is fine because the product should be deleted anyway
|
|
325
|
+
logger.info(f'deletion of product {product_id} failed because it does not exist')
|
|
326
|
+
return
|
|
327
|
+
raise e
|
|
328
|
+
|
|
329
|
+
def scan_result(self, product_id: int) -> bm.AnalysisResult:
|
|
330
|
+
url = self._routes.product(product_id=product_id)
|
|
331
|
+
|
|
332
|
+
result = (
|
|
333
|
+
self._get(
|
|
334
|
+
url=url,
|
|
335
|
+
)
|
|
336
|
+
.json()
|
|
337
|
+
.get('results', {})
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
return dacite.from_dict(
|
|
341
|
+
data_class=bm.AnalysisResult,
|
|
342
|
+
data=kebab_to_snake_case_keys(result),
|
|
343
|
+
config=dacite.Config(
|
|
344
|
+
cast=[enum.Enum],
|
|
345
|
+
),
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
def wait_for_scan_result(
|
|
349
|
+
self,
|
|
350
|
+
product_id: int,
|
|
351
|
+
polling_interval_seconds: int = 60,
|
|
352
|
+
) -> bm.AnalysisResult:
|
|
353
|
+
def scan_finished():
|
|
354
|
+
result = self.scan_result(product_id=product_id)
|
|
355
|
+
if result.status is bm.ProcessingStatus.READY:
|
|
356
|
+
return result
|
|
357
|
+
elif result.status is bm.ProcessingStatus.FAILED:
|
|
358
|
+
# failed scans do not contain package infos, raise to prevent side effects
|
|
359
|
+
raise RuntimeError(f'scan failed; {result.fail_reason=}')
|
|
360
|
+
else:
|
|
361
|
+
return False
|
|
362
|
+
|
|
363
|
+
result = scan_finished()
|
|
364
|
+
while not result:
|
|
365
|
+
# keep polling until result is ready
|
|
366
|
+
time.sleep(polling_interval_seconds)
|
|
367
|
+
result = scan_finished()
|
|
368
|
+
return result
|
|
369
|
+
|
|
370
|
+
def list_apps(self, group_id=None, custom_attribs={}) -> list[bm.Product]:
|
|
371
|
+
# BDBA checks for substring match only.
|
|
372
|
+
def full_match(analysis_result_attribs):
|
|
373
|
+
if not custom_attribs:
|
|
374
|
+
return True
|
|
375
|
+
for attrib in custom_attribs:
|
|
376
|
+
# attrib is guaranteed to be a key in analysis_result_attribs at this point
|
|
377
|
+
if analysis_result_attribs[attrib] != custom_attribs[attrib]:
|
|
378
|
+
return False
|
|
379
|
+
return True
|
|
380
|
+
|
|
381
|
+
def _iter_matching_products(url: str):
|
|
382
|
+
res = self._get(url=url)
|
|
383
|
+
res.raise_for_status()
|
|
384
|
+
res = res.json()
|
|
385
|
+
products: list[dict] = res['products']
|
|
386
|
+
|
|
387
|
+
for product in products:
|
|
388
|
+
if not full_match(product.get('custom_data')):
|
|
389
|
+
continue
|
|
390
|
+
yield dacite.from_dict(
|
|
391
|
+
data_class=bm.Product,
|
|
392
|
+
data=product,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
if next_page_url := res.get('next'):
|
|
396
|
+
yield from _iter_matching_products(url=next_page_url)
|
|
397
|
+
|
|
398
|
+
url = self._routes.apps(group_id=group_id, custom_attribs=custom_attribs)
|
|
399
|
+
return list(_iter_matching_products(url=url))
|
|
400
|
+
|
|
401
|
+
def set_metadata(self, product_id: int, custom_attribs: dict):
|
|
402
|
+
url = self._routes.product_custom_data(product_id=product_id)
|
|
403
|
+
headers = self._metadata_dict(custom_attribs)
|
|
404
|
+
|
|
405
|
+
result = self._post(
|
|
406
|
+
url=url,
|
|
407
|
+
headers=headers,
|
|
408
|
+
)
|
|
409
|
+
return result.json()
|
|
410
|
+
|
|
411
|
+
def metadata(self, product_id: int):
|
|
412
|
+
url = self._routes.product_custom_data(product_id=product_id)
|
|
413
|
+
|
|
414
|
+
result = self._post(
|
|
415
|
+
url=url,
|
|
416
|
+
headers={},
|
|
417
|
+
)
|
|
418
|
+
return result.json().get('custom_data', {})
|
|
419
|
+
|
|
420
|
+
def get_triages(
|
|
421
|
+
self,
|
|
422
|
+
component_name: str,
|
|
423
|
+
component_version: str,
|
|
424
|
+
vuln_id: str,
|
|
425
|
+
scope: str,
|
|
426
|
+
description: str,
|
|
427
|
+
):
|
|
428
|
+
url = self._routes.triage()
|
|
429
|
+
result = self._get(
|
|
430
|
+
url=url,
|
|
431
|
+
params={
|
|
432
|
+
'component': component_name,
|
|
433
|
+
'vuln_id': vuln_id,
|
|
434
|
+
'scope': scope,
|
|
435
|
+
'version': component_version,
|
|
436
|
+
'description': description,
|
|
437
|
+
},
|
|
438
|
+
).json()['triages']
|
|
439
|
+
|
|
440
|
+
return [
|
|
441
|
+
dacite.from_dict(
|
|
442
|
+
data_class=bm.Triage,
|
|
443
|
+
data=triage_dict,
|
|
444
|
+
config=dacite.Config(
|
|
445
|
+
type_hooks={
|
|
446
|
+
datetime.datetime: dateutil.parser.isoparse,
|
|
447
|
+
},
|
|
448
|
+
cast=[enum.Enum],
|
|
449
|
+
),
|
|
450
|
+
)
|
|
451
|
+
for triage_dict in result
|
|
452
|
+
]
|
|
453
|
+
|
|
454
|
+
def add_triage(
|
|
455
|
+
self,
|
|
456
|
+
triage: bm.Triage,
|
|
457
|
+
scope: bm.TriageScope = None,
|
|
458
|
+
product_id=None,
|
|
459
|
+
group_id=None,
|
|
460
|
+
component_version=None,
|
|
461
|
+
):
|
|
462
|
+
"""
|
|
463
|
+
adds an existing BDBA triage to a specified target. The existing triage is usually
|
|
464
|
+
retrieved from an already uploaded product (which is represented by `AnalysisResult`).
|
|
465
|
+
This method is offered to support "transporting" existing triages.
|
|
466
|
+
|
|
467
|
+
Note that - depending on the effective target scope, the `product_id`, `group_id` formal
|
|
468
|
+
parameters are either required or forbidden.
|
|
469
|
+
|
|
470
|
+
Note that BDBA will only accept triages for matching (component, vulnerabilities,
|
|
471
|
+
version) tuples. In particular, triages for different component versions will be silently
|
|
472
|
+
ignored. Explicitly pass `component_version` of target BDBA app (/product) to force
|
|
473
|
+
BDBA into accepting the given triage.
|
|
474
|
+
|
|
475
|
+
@param triage: the triage to "copy"
|
|
476
|
+
@param scope: if given, overrides the triage's scope
|
|
477
|
+
@param product_id: target product_id. required iff scope in FN, FH, R
|
|
478
|
+
@param group_id: target group_id. required iff scope is G(ROUP)
|
|
479
|
+
@param component_version: overwrite target component version
|
|
480
|
+
"""
|
|
481
|
+
# if no scope is set, use the one from passed triage
|
|
482
|
+
scope = scope if scope else triage.scope
|
|
483
|
+
|
|
484
|
+
# depending on the scope, different arguments are required
|
|
485
|
+
if scope == bm.TriageScope.ACCOUNT_WIDE:
|
|
486
|
+
pass
|
|
487
|
+
elif scope in (bm.TriageScope.FILE_NAME, bm.TriageScope.FILE_HASH, bm.TriageScope.RESULT):
|
|
488
|
+
if product_id is None:
|
|
489
|
+
raise ValueError(f'{product_id=} must not be None')
|
|
490
|
+
elif scope == bm.TriageScope.GROUP:
|
|
491
|
+
if group_id is None:
|
|
492
|
+
raise ValueError(f'{group_id=} must not be None')
|
|
493
|
+
else:
|
|
494
|
+
raise NotImplementedError()
|
|
495
|
+
|
|
496
|
+
if not component_version:
|
|
497
|
+
component_version = triage.version
|
|
498
|
+
|
|
499
|
+
# "copy" data from existing triage
|
|
500
|
+
triage_dict = {
|
|
501
|
+
'component': triage.component,
|
|
502
|
+
'version': component_version,
|
|
503
|
+
'vulns': [triage.vuln_id],
|
|
504
|
+
'scope': triage.scope.value,
|
|
505
|
+
'reason': triage.reason,
|
|
506
|
+
'description': triage.description,
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if product_id:
|
|
510
|
+
triage_dict['product_id'] = product_id
|
|
511
|
+
|
|
512
|
+
if group_id:
|
|
513
|
+
triage_dict['group_id'] = group_id
|
|
514
|
+
|
|
515
|
+
return self.add_triage_raw(triage_dict=triage_dict)
|
|
516
|
+
|
|
517
|
+
def add_triage_raw(self, triage_dict: dict):
|
|
518
|
+
url = self._routes.triage()
|
|
519
|
+
try:
|
|
520
|
+
res = self._put(
|
|
521
|
+
url=url,
|
|
522
|
+
json=triage_dict,
|
|
523
|
+
).json()
|
|
524
|
+
return res
|
|
525
|
+
except requests.exceptions.HTTPError as e:
|
|
526
|
+
resp: requests.Response = e.response
|
|
527
|
+
logger.warning(f'{url=} {resp.status_code=} {resp.content=} {triage_dict=}')
|
|
528
|
+
traceback.print_exc()
|
|
529
|
+
raise e
|
|
530
|
+
|
|
531
|
+
# --- "rest" routes (undocumented API)
|
|
532
|
+
def set_product_name(self, product_id: int, name: str):
|
|
533
|
+
url = self._routes.product(product_id)
|
|
534
|
+
|
|
535
|
+
if bdba.limits.fits(name, limit=bdba.limits.app_name):
|
|
536
|
+
product_name = name
|
|
537
|
+
else:
|
|
538
|
+
product_name = bdba.limits.trim(name, limit=bdba.limits.app_name)
|
|
539
|
+
logger.warning(
|
|
540
|
+
f'{name=} exceeds character limit of {bdba.limits.app_name} and was truncated '
|
|
541
|
+
f'to {product_name}',
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
self._patch(
|
|
545
|
+
url=url,
|
|
546
|
+
json={'name': product_name},
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
def rescan(self, product_id: int):
|
|
550
|
+
url = self._routes.rescan(product_id)
|
|
551
|
+
self._post(
|
|
552
|
+
url=url,
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
def set_component_version(
|
|
556
|
+
self,
|
|
557
|
+
component_name: str,
|
|
558
|
+
component_version: str,
|
|
559
|
+
objects: list[str],
|
|
560
|
+
scope: bm.VersionOverrideScope = bm.VersionOverrideScope.APP,
|
|
561
|
+
app_id: int = None,
|
|
562
|
+
group_id: int = None,
|
|
563
|
+
):
|
|
564
|
+
"""
|
|
565
|
+
@param component_name: component name as reported by bdba
|
|
566
|
+
@param component_version: version to set as override
|
|
567
|
+
@param objects: list of sha1-digests (as reported by BDBA)
|
|
568
|
+
@param scope: see VersionOverrideScope enum
|
|
569
|
+
"""
|
|
570
|
+
url = self._routes.version_override()
|
|
571
|
+
|
|
572
|
+
override_dict = {
|
|
573
|
+
'component': component_name,
|
|
574
|
+
'version': component_version,
|
|
575
|
+
'objects': objects,
|
|
576
|
+
'scope': scope.value,
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if scope is bm.VersionOverrideScope.APP:
|
|
580
|
+
if not app_id:
|
|
581
|
+
raise RuntimeError('An App ID is required when overriding versions with App scope.')
|
|
582
|
+
override_dict['app_scope'] = app_id
|
|
583
|
+
elif scope is bm.VersionOverrideScope.GROUP:
|
|
584
|
+
if not group_id:
|
|
585
|
+
raise RuntimeError(
|
|
586
|
+
'A Group ID is required when overriding versions with Group scope.',
|
|
587
|
+
)
|
|
588
|
+
override_dict['group_scope'] = group_id
|
|
589
|
+
else:
|
|
590
|
+
raise NotImplementedError
|
|
591
|
+
|
|
592
|
+
return self._put(
|
|
593
|
+
url=url,
|
|
594
|
+
json=[override_dict],
|
|
595
|
+
).json()
|
|
596
|
+
|
|
597
|
+
def pdf_report(self, product_id: int, cvss_version: bm.CVSSVersion = bm.CVSSVersion.V3):
|
|
598
|
+
url = self._routes.pdf_report(product_id)
|
|
599
|
+
|
|
600
|
+
if cvss_version is bm.CVSSVersion.V2:
|
|
601
|
+
cvss_version_number = 2
|
|
602
|
+
elif cvss_version is bm.CVSSVersion.V3:
|
|
603
|
+
cvss_version_number = 3
|
|
604
|
+
else:
|
|
605
|
+
raise NotImplementedError(cvss_version)
|
|
606
|
+
|
|
607
|
+
response = self._get(
|
|
608
|
+
url=url,
|
|
609
|
+
params={'cvss_version': cvss_version_number},
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
return response.content
|
|
613
|
+
|
|
614
|
+
def api_key(self) -> dict:
|
|
615
|
+
return self._get(url=self._routes.api_key())
|
|
616
|
+
|
|
617
|
+
def create_key(
|
|
618
|
+
self,
|
|
619
|
+
validity_seconds: int,
|
|
620
|
+
timeout: int = 60,
|
|
621
|
+
) -> dict:
|
|
622
|
+
return self._post(
|
|
623
|
+
url=self._routes.api_key(),
|
|
624
|
+
json={'validity': validity_seconds},
|
|
625
|
+
timeout=timeout,
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
def bdio_export(
|
|
629
|
+
self,
|
|
630
|
+
product_id: int | str,
|
|
631
|
+
) -> bm.BDIO:
|
|
632
|
+
url = self._routes.export_product(product_id)
|
|
633
|
+
response = self._get(url=url)
|
|
634
|
+
response.raise_for_status()
|
|
635
|
+
|
|
636
|
+
raw_data = response.json()
|
|
637
|
+
return dacite.from_dict(
|
|
638
|
+
data_class=bm.BDIO,
|
|
639
|
+
data=dict(
|
|
640
|
+
**raw_data,
|
|
641
|
+
id=raw_data.get('@id'),
|
|
642
|
+
publisher_version=raw_data.get('publisherVersion'),
|
|
643
|
+
creation_datetime=raw_data.get('creationDateTime'),
|
|
644
|
+
entries=raw_data.get('@graph'),
|
|
645
|
+
),
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
def export_sbom(
|
|
649
|
+
self,
|
|
650
|
+
product_id: int | str,
|
|
651
|
+
sbom_format: bm.BdbaSbomFormat,
|
|
652
|
+
) -> dict | bm.BDIO:
|
|
653
|
+
url = self._routes.export_product(product_id, sbom_format=sbom_format)
|
|
654
|
+
|
|
655
|
+
response = self._get(url=url)
|
|
656
|
+
response_raw = response.json()
|
|
657
|
+
|
|
658
|
+
if sbom_format is bm.BdbaSbomFormat.BDIO:
|
|
659
|
+
return dacite.from_dict(
|
|
660
|
+
data_class=bm.BDIO,
|
|
661
|
+
data=dict(
|
|
662
|
+
**response_raw,
|
|
663
|
+
id=response_raw.get('@id'),
|
|
664
|
+
publisher_version=response_raw.get('publisherVersion'),
|
|
665
|
+
creation_datetime=response_raw.get('creationDateTime'),
|
|
666
|
+
entries=response_raw.get('@graph'),
|
|
667
|
+
),
|
|
668
|
+
)
|
|
669
|
+
return response_raw
|
bdba/limits.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""
|
|
2
|
+
(character) limits for BDBA-api
|
|
3
|
+
|
|
4
|
+
limits are either found by trail and error or from the documentation
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
app_name = 255
|
|
8
|
+
file_name = 241
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def fits(
|
|
12
|
+
value: str,
|
|
13
|
+
/,
|
|
14
|
+
limit: int,
|
|
15
|
+
) -> bool:
|
|
16
|
+
return len(value) <= limit
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def trim(
|
|
20
|
+
value: str,
|
|
21
|
+
/,
|
|
22
|
+
limit: int,
|
|
23
|
+
) -> str:
|
|
24
|
+
return value[:limit]
|
bdba/model.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Gardener contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
import collections.abc
|
|
7
|
+
import dataclasses
|
|
8
|
+
import datetime
|
|
9
|
+
import enum
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import typing
|
|
13
|
+
|
|
14
|
+
import dacite
|
|
15
|
+
import dateutil.parser
|
|
16
|
+
|
|
17
|
+
import bdba.util as bu
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class VersionOverrideScope(enum.IntEnum):
|
|
24
|
+
APP = 1
|
|
25
|
+
GROUP = 2
|
|
26
|
+
GLOBAL = 3
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ProcessingStatus(enum.StrEnum):
|
|
30
|
+
BUSY = 'B'
|
|
31
|
+
READY = 'R'
|
|
32
|
+
FAILED = 'F'
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CVSSVersion(enum.StrEnum):
|
|
36
|
+
V2 = 'CVSSv2'
|
|
37
|
+
V3 = 'CVSSv3'
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TriageScope(enum.StrEnum):
|
|
41
|
+
ACCOUNT_WIDE = 'CA'
|
|
42
|
+
FILE_NAME = 'FN'
|
|
43
|
+
FILE_HASH = 'FH'
|
|
44
|
+
RESULT = 'R'
|
|
45
|
+
GROUP = 'G'
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ProcessingMode(enum.StrEnum):
|
|
49
|
+
RESCAN = 'rescan'
|
|
50
|
+
FORCE_UPLOAD = 'force_upload'
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclasses.dataclass
|
|
54
|
+
class Product:
|
|
55
|
+
product_id: int
|
|
56
|
+
name: str
|
|
57
|
+
custom_data: dict[str, str] = dataclasses.field(default_factory=dict)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclasses.dataclass
|
|
61
|
+
class Triage:
|
|
62
|
+
id: int
|
|
63
|
+
vuln_id: str
|
|
64
|
+
component: str
|
|
65
|
+
version: str | None
|
|
66
|
+
scope: TriageScope
|
|
67
|
+
reason: str
|
|
68
|
+
description: str | None
|
|
69
|
+
modified: datetime.datetime
|
|
70
|
+
user: dict
|
|
71
|
+
|
|
72
|
+
def __repr__(self):
|
|
73
|
+
return (
|
|
74
|
+
f'{self.__class__.__name__}: {self.id} ({self.component} {self.version}, '
|
|
75
|
+
f'{self.vuln_id}, Scope: {self.scope})'
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def __eq__(self, other):
|
|
79
|
+
if not isinstance(other, Triage):
|
|
80
|
+
return False
|
|
81
|
+
if self.vuln_id != other.vuln_id:
|
|
82
|
+
return False
|
|
83
|
+
if self.component != other.component:
|
|
84
|
+
return False
|
|
85
|
+
if self.description != other.description:
|
|
86
|
+
return False
|
|
87
|
+
return True
|
|
88
|
+
|
|
89
|
+
def __hash__(self):
|
|
90
|
+
return hash((self.vuln_id, self.component, self.description))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclasses.dataclass
|
|
94
|
+
class Vulnerability:
|
|
95
|
+
vuln: dict
|
|
96
|
+
exact: bool | None
|
|
97
|
+
triage: list[Triage]
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def historical(self):
|
|
101
|
+
return not self.exact
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def cve(self) -> str:
|
|
105
|
+
return self.vuln.get('cve')
|
|
106
|
+
|
|
107
|
+
def cve_severity(
|
|
108
|
+
self,
|
|
109
|
+
cvss_version: CVSSVersion = CVSSVersion.V3,
|
|
110
|
+
) -> float:
|
|
111
|
+
if cvss_version is CVSSVersion.V3:
|
|
112
|
+
return float(self.vuln.get('cvss3_score'))
|
|
113
|
+
elif cvss_version is CVSSVersion.V2:
|
|
114
|
+
return float(self.vuln.get('cvss'))
|
|
115
|
+
else:
|
|
116
|
+
raise ValueError(f'{cvss_version} not supported')
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def cvss(self) -> dict | None:
|
|
120
|
+
# ignore cvss2_vector for now
|
|
121
|
+
if not (cvss_vector := self.vuln.get('cvss3_vector')):
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
return cvss_vector
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def summary(self) -> str:
|
|
128
|
+
return self.vuln.get('summary')
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def has_triage(self) -> bool:
|
|
132
|
+
return bool(self.triage)
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def triages(self) -> collections.abc.Generator[Triage, None, None]:
|
|
136
|
+
if not self.has_triage:
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
yield from self.triage
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def okay_to_skip(self) -> bool:
|
|
143
|
+
"""
|
|
144
|
+
Indiacates whether it is okay to not store this vulnerability and the delivery-db but just
|
|
145
|
+
ignore it. This is the case if the vulnerability
|
|
146
|
+
- is declared as historical: package in the detected version is not actually affected
|
|
147
|
+
- has a missing CVSS or severity: the vulnerability mighth still be in dispute upstream
|
|
148
|
+
"""
|
|
149
|
+
return self.historical or not self.cvss or not self.cve_severity()
|
|
150
|
+
|
|
151
|
+
def __repr__(self):
|
|
152
|
+
return f'{self.__class__.__name__}: {self.cve}'
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclasses.dataclass
|
|
156
|
+
class License:
|
|
157
|
+
name: str
|
|
158
|
+
type: str | None
|
|
159
|
+
url: str | None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@dataclasses.dataclass
|
|
163
|
+
class ExtendedObject:
|
|
164
|
+
name: str | None
|
|
165
|
+
sha1: str | None
|
|
166
|
+
extended_fullpath: list[dict]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclasses.dataclass
|
|
170
|
+
class Component:
|
|
171
|
+
lib: str
|
|
172
|
+
version: str | None
|
|
173
|
+
vulns: list[dict] | None
|
|
174
|
+
license: License | None
|
|
175
|
+
licenses: dict | None
|
|
176
|
+
extended_objects: list[ExtendedObject] = dataclasses.field(default_factory=list)
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def name(self) -> str:
|
|
180
|
+
return self.lib
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def vulnerabilities(self) -> collections.abc.Generator[Vulnerability, None, None]:
|
|
184
|
+
for vuln in self.vulns or []:
|
|
185
|
+
if vuln['vuln'].get('cve'):
|
|
186
|
+
yield dacite.from_dict(
|
|
187
|
+
data_class=Vulnerability,
|
|
188
|
+
data=vuln,
|
|
189
|
+
config=dacite.Config(
|
|
190
|
+
type_hooks={
|
|
191
|
+
datetime.datetime: dateutil.parser.isoparse,
|
|
192
|
+
},
|
|
193
|
+
cast=[enum.Enum],
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def iter_licenses(self) -> collections.abc.Generator[License, None, None]:
|
|
199
|
+
"""
|
|
200
|
+
Wrapper to consume package's licenses and prefer those stored in the `licenses` property
|
|
201
|
+
over the one in the `license` property. Rationale: BDBA is known to always store the
|
|
202
|
+
greatest license version under `license`, and the "correct" one under `licenses`.
|
|
203
|
+
"""
|
|
204
|
+
if not self.licenses:
|
|
205
|
+
if self.license:
|
|
206
|
+
yield self.license
|
|
207
|
+
return
|
|
208
|
+
|
|
209
|
+
yield from [
|
|
210
|
+
dacite.from_dict(
|
|
211
|
+
data_class=License,
|
|
212
|
+
data=license_raw,
|
|
213
|
+
)
|
|
214
|
+
for license_raw in self.licenses.get('licenses')
|
|
215
|
+
]
|
|
216
|
+
|
|
217
|
+
def __repr__(self):
|
|
218
|
+
return f'{self.__class__.__name__}: {self.name} {self.version or "version not detected"}'
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@dataclasses.dataclass
|
|
222
|
+
class Result:
|
|
223
|
+
product_id: int
|
|
224
|
+
report_url: str
|
|
225
|
+
filename: str | None
|
|
226
|
+
stale: bool | None
|
|
227
|
+
rescan_possible: bool | None
|
|
228
|
+
|
|
229
|
+
@property
|
|
230
|
+
def base_url(self) -> str:
|
|
231
|
+
parsed_url = bu.urlparse(self.report_url)
|
|
232
|
+
return f'{parsed_url.scheme}://{parsed_url.netloc}'
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def display_name(self) -> str:
|
|
236
|
+
return self.filename or '<None>'
|
|
237
|
+
|
|
238
|
+
def __repr__(self):
|
|
239
|
+
return f'{self.__class__.__name__}: {self.display_name} ({self.product_id})'
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclasses.dataclass
|
|
243
|
+
class AnalysisResult(Result):
|
|
244
|
+
group_id: int
|
|
245
|
+
status: ProcessingStatus
|
|
246
|
+
name: str | None
|
|
247
|
+
fail_reason: str | None
|
|
248
|
+
components: list[Component] = dataclasses.field(default_factory=list)
|
|
249
|
+
custom_data: dict[str, str] = dataclasses.field(default_factory=dict)
|
|
250
|
+
binary_bytes: int | None = None
|
|
251
|
+
scanned_bytes: int | None = None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@dataclasses.dataclass
|
|
255
|
+
class BDIO:
|
|
256
|
+
id: str
|
|
257
|
+
name: str
|
|
258
|
+
publisher: str
|
|
259
|
+
publisher_version: str
|
|
260
|
+
entries: list[dict[str, typing.Any]]
|
|
261
|
+
|
|
262
|
+
def as_blackduck_bytes(self) -> bytes:
|
|
263
|
+
return json.dumps(
|
|
264
|
+
{
|
|
265
|
+
'@id': self.id,
|
|
266
|
+
'name': self.name,
|
|
267
|
+
'publisher': self.publisher,
|
|
268
|
+
'publisherVersion': self.publisher_version,
|
|
269
|
+
'@graph': self.entries,
|
|
270
|
+
},
|
|
271
|
+
indent=2,
|
|
272
|
+
).encode('utf-8')
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
#############################################################################
|
|
276
|
+
## upload result model
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class UploadStatus(enum.IntEnum):
|
|
280
|
+
SKIPPED = 1
|
|
281
|
+
PENDING = 2
|
|
282
|
+
DONE = 4
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class BdbaSbomFormat(enum.StrEnum):
|
|
286
|
+
CYCLONEDX = 'cyclonedx'
|
|
287
|
+
SPDX = 'spdx'
|
|
288
|
+
BDIO = 'bdio'
|
bdba/util.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import urllib.parse
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def urljoin(*parts):
|
|
5
|
+
if len(parts) == 1:
|
|
6
|
+
return parts[0]
|
|
7
|
+
first = parts[0]
|
|
8
|
+
last = parts[-1]
|
|
9
|
+
middle = parts[1:-1]
|
|
10
|
+
|
|
11
|
+
first = first.rstrip('/')
|
|
12
|
+
middle = list(map(lambda s: s.strip('/'), middle))
|
|
13
|
+
last = last.lstrip('/')
|
|
14
|
+
|
|
15
|
+
return '/'.join([first] + middle + [last])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def urlparse(url: str) -> urllib.parse.ParseResult:
|
|
19
|
+
if '://' not in url:
|
|
20
|
+
url = f'x://{url}'
|
|
21
|
+
|
|
22
|
+
return urllib.parse.urlparse(url)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
Version 2.0, January 2004
|
|
3
|
+
http://www.apache.org/licenses/
|
|
4
|
+
|
|
5
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
6
|
+
|
|
7
|
+
1. Definitions.
|
|
8
|
+
|
|
9
|
+
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
|
10
|
+
|
|
11
|
+
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
|
12
|
+
|
|
13
|
+
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
|
14
|
+
|
|
15
|
+
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
|
16
|
+
|
|
17
|
+
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
|
18
|
+
|
|
19
|
+
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
|
20
|
+
|
|
21
|
+
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
|
22
|
+
|
|
23
|
+
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
|
24
|
+
|
|
25
|
+
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
|
26
|
+
|
|
27
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
|
28
|
+
|
|
29
|
+
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
|
30
|
+
|
|
31
|
+
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
|
32
|
+
|
|
33
|
+
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
|
34
|
+
|
|
35
|
+
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
|
|
36
|
+
|
|
37
|
+
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
|
|
38
|
+
|
|
39
|
+
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
|
|
40
|
+
|
|
41
|
+
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
|
|
42
|
+
|
|
43
|
+
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
|
44
|
+
|
|
45
|
+
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
|
46
|
+
|
|
47
|
+
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
|
48
|
+
|
|
49
|
+
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
|
50
|
+
|
|
51
|
+
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
|
52
|
+
|
|
53
|
+
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
|
54
|
+
|
|
55
|
+
END OF TERMS AND CONDITIONS
|
|
56
|
+
|
|
57
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
58
|
+
|
|
59
|
+
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
|
|
60
|
+
|
|
61
|
+
Copyright [yyyy] [name of copyright owner]
|
|
62
|
+
|
|
63
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
64
|
+
you may not use this file except in compliance with the License.
|
|
65
|
+
You may obtain a copy of the License at
|
|
66
|
+
|
|
67
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
68
|
+
|
|
69
|
+
Unless required by applicable law or agreed to in writing, software
|
|
70
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
71
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
72
|
+
See the License for the specific language governing permissions and
|
|
73
|
+
limitations under the License.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
bdba/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
bdba/client.py,sha256=-lMuuNcVdYmAa4cxgwEYti6KORjjB4zLa_T6Q1vTEU8,21554
|
|
3
|
+
bdba/limits.py,sha256=yJ30hR0LGoAYkdpjL9N9ZQPxT4gTjFWMZr5m4vl4288,321
|
|
4
|
+
bdba/model.py,sha256=BLJ39x9vg1Ro5DJzzX0-9F6zOVG0lc67g3dasNx766E,7161
|
|
5
|
+
bdba/util.py,sha256=kG1OIHR6H5EAnIw7ZO3cTf1DLsfB5oM1jSKsnDWh6l4,465
|
|
6
|
+
bdba_client-0.13.0.dist-info/LICENSE,sha256=B05uMshqTA74s-0ltyHKI6yoPfJ3zYgQbvcXfDVGFf8,10280
|
|
7
|
+
bdba_client-0.13.0.dist-info/METADATA,sha256=80uCHMpRG0YdadqeKgBxIzF2ip5G9v2sHA9dRExuZ7Q,208
|
|
8
|
+
bdba_client-0.13.0.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
|
|
9
|
+
bdba_client-0.13.0.dist-info/top_level.txt,sha256=EAfeoUT6W8sCNbjXMFLIGgbKNzkFzSswnLgodqNNOxA,5
|
|
10
|
+
bdba_client-0.13.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
bdba
|