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 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
+ Metadata-Version: 2.1
2
+ Name: bdba-client
3
+ Version: 0.13.0
4
+ Requires-Python: >=3.11
5
+ License-File: LICENSE
6
+ Requires-Dist: cachecontrol
7
+ Requires-Dist: dacite
8
+ Requires-Dist: python-dateutil
9
+ Requires-Dist: requests
10
+
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.42.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ bdba