|
29 | 29 | from collections import Counter |
30 | 30 | from collections import defaultdict |
31 | 31 | from contextlib import suppress |
| 32 | +from itertools import chain |
32 | 33 | from itertools import groupby |
33 | 34 | from operator import itemgetter |
34 | 35 | from pathlib import Path |
@@ -1486,12 +1487,12 @@ def package_count(self): |
1486 | 1487 | @cached_property |
1487 | 1488 | def vulnerable_package_count(self): |
1488 | 1489 | """Return the number of vulnerable packages related to this project.""" |
1489 | | - return self.discoveredpackages.vulnerable().count() |
| 1490 | + return self.vulnerable_packages.count() |
1490 | 1491 |
|
1491 | 1492 | @cached_property |
1492 | 1493 | def vulnerable_dependency_count(self): |
1493 | 1494 | """Return the number of vulnerable dependencies related to this project.""" |
1494 | | - return self.discovereddependencies.vulnerable().count() |
| 1495 | + return self.vulnerable_dependencies.count() |
1495 | 1496 |
|
1496 | 1497 | @cached_property |
1497 | 1498 | def dependency_count(self): |
@@ -1537,6 +1538,49 @@ def relation_count(self): |
1537 | 1538 | """Return the number of relations related to this project.""" |
1538 | 1539 | return self.codebaserelations.count() |
1539 | 1540 |
|
| 1541 | + @cached_property |
| 1542 | + def vulnerable_packages(self): |
| 1543 | + """Return a QuerySet of vulnerable packages.""" |
| 1544 | + return self.discoveredpackages.vulnerable() |
| 1545 | + |
| 1546 | + @cached_property |
| 1547 | + def vulnerable_dependencies(self): |
| 1548 | + """Return a QuerySet of vulnerable dependencies.""" |
| 1549 | + return self.discovereddependencies.vulnerable() |
| 1550 | + |
| 1551 | + @property |
| 1552 | + def package_vulnerabilities(self): |
| 1553 | + """Return the list of package vulnerabilities.""" |
| 1554 | + return self.vulnerable_packages.get_vulnerabilities_list() |
| 1555 | + |
| 1556 | + @property |
| 1557 | + def dependency_vulnerabilities(self): |
| 1558 | + """Return the list of dependency vulnerabilities.""" |
| 1559 | + return self.vulnerable_dependencies.get_vulnerabilities_list() |
| 1560 | + |
| 1561 | + @property |
| 1562 | + def vulnerabilities(self): |
| 1563 | + """ |
| 1564 | + Return a dict of all vulnerabilities affecting this project. |
| 1565 | +
|
| 1566 | + Combines package and dependency vulnerabilities, keyed by vulnerability_id. |
| 1567 | + Each vulnerability includes an "affects" list of all affected packages |
| 1568 | + and dependencies. |
| 1569 | + """ |
| 1570 | + vulnerabilities_dict = {} |
| 1571 | + # Process both packages and dependencies |
| 1572 | + querysets = [self.vulnerable_packages, self.vulnerable_dependencies] |
| 1573 | + |
| 1574 | + for queryset in querysets: |
| 1575 | + vulnerabilities = queryset.get_vulnerabilities_dict() |
| 1576 | + for vcid, vuln_data in vulnerabilities.items(): |
| 1577 | + if vcid in vulnerabilities_dict: |
| 1578 | + vulnerabilities_dict[vcid]["affects"].extend(vuln_data["affects"]) |
| 1579 | + else: |
| 1580 | + vulnerabilities_dict[vcid] = vuln_data |
| 1581 | + |
| 1582 | + return vulnerabilities_dict |
| 1583 | + |
1540 | 1584 | @cached_property |
1541 | 1585 | def has_single_resource(self): |
1542 | 1586 | """ |
@@ -3284,16 +3328,70 @@ def is_vulnerable(self): |
3284 | 3328 |
|
3285 | 3329 |
|
3286 | 3330 | class VulnerabilityQuerySetMixin: |
| 3331 | + AFFECTED_BY_FIELD = "affected_by_vulnerabilities" |
| 3332 | + |
3287 | 3333 | def vulnerable(self): |
3288 | | - return self.filter(~Q(affected_by_vulnerabilities__in=EMPTY_VALUES)) |
| 3334 | + return self.filter(~Q(**{f"{self.AFFECTED_BY_FIELD}__in": EMPTY_VALUES})) |
3289 | 3335 |
|
3290 | 3336 | def vulnerable_ordered(self): |
3291 | 3337 | return ( |
3292 | 3338 | self.vulnerable() |
3293 | | - .only_package_url_fields(extra=["affected_by_vulnerabilities"]) |
| 3339 | + .only_package_url_fields(extra=[self.AFFECTED_BY_FIELD]) |
3294 | 3340 | .order_by_package_url() |
3295 | 3341 | ) |
3296 | 3342 |
|
| 3343 | + def get_vulnerabilities_list(self): |
| 3344 | + """ |
| 3345 | + Return a deduplicated, sorted flat list of all vulnerabilities from the |
| 3346 | + queryset. |
| 3347 | +
|
| 3348 | + Extracts and flattens the affected_by_vulnerabilities field from |
| 3349 | + all objects in the queryset. Removes duplicates based on vulnerability_id |
| 3350 | + while preserving the first occurrence of each unique vulnerability. |
| 3351 | + """ |
| 3352 | + vulnerabilities_lists = self.values_list(self.AFFECTED_BY_FIELD, flat=True) |
| 3353 | + flatten_vulnerabilities = chain.from_iterable(vulnerabilities_lists) |
| 3354 | + |
| 3355 | + # Deduplicate by vulnerability_id while preserving order |
| 3356 | + unique_vulnerabilities = { |
| 3357 | + vuln["vulnerability_id"]: vuln for vuln in flatten_vulnerabilities |
| 3358 | + } |
| 3359 | + |
| 3360 | + return sorted( |
| 3361 | + unique_vulnerabilities.values(), key=itemgetter("vulnerability_id") |
| 3362 | + ) |
| 3363 | + |
| 3364 | + def get_vulnerabilities_dict(self): |
| 3365 | + """ |
| 3366 | + Return a dict of vulnerabilities keyed by vulnerability_id. |
| 3367 | +
|
| 3368 | + Each vulnerability includes an "affects" list containing all |
| 3369 | + objects from this queryset affected by that vulnerability. |
| 3370 | +
|
| 3371 | + Returns: |
| 3372 | + dict: { |
| 3373 | + 'VCID-1': { |
| 3374 | + 'vulnerability_id': 'VCID-1', |
| 3375 | + 'affects': [obj1, obj2, ...] |
| 3376 | + }, |
| 3377 | + ... |
| 3378 | + } |
| 3379 | +
|
| 3380 | + """ |
| 3381 | + vulnerabilities_dict = {} |
| 3382 | + |
| 3383 | + for obj in self.vulnerable_ordered(): |
| 3384 | + for vulnerability in obj.affected_by_vulnerabilities: |
| 3385 | + vcid = vulnerability.get("vulnerability_id") |
| 3386 | + if not vcid: |
| 3387 | + continue |
| 3388 | + |
| 3389 | + if vcid not in vulnerabilities_dict: |
| 3390 | + vulnerabilities_dict[vcid] = {**vulnerability, "affects": []} |
| 3391 | + vulnerabilities_dict[vcid]["affects"].append(obj) |
| 3392 | + |
| 3393 | + return vulnerabilities_dict |
| 3394 | + |
3297 | 3395 |
|
3298 | 3396 | class DiscoveredPackageQuerySet( |
3299 | 3397 | VulnerabilityQuerySetMixin, |
@@ -3324,7 +3422,7 @@ def only_package_url_fields(self, extra=None): |
3324 | 3422 | if not extra: |
3325 | 3423 | extra = [] |
3326 | 3424 |
|
3327 | | - return self.only("uuid", *PACKAGE_URL_FIELDS, *extra) |
| 3425 | + return self.only("uuid", *PACKAGE_URL_FIELDS, "project_id", *extra) |
3328 | 3426 |
|
3329 | 3427 | def filter(self, *args, **kwargs): |
3330 | 3428 | """Add support for using ``package_url`` as a field lookup.""" |
@@ -3945,7 +4043,7 @@ def only_package_url_fields(self, extra=None): |
3945 | 4043 | if not extra: |
3946 | 4044 | extra = [] |
3947 | 4045 |
|
3948 | | - return self.only("dependency_uid", *PACKAGE_URL_FIELDS, *extra) |
| 4046 | + return self.only("dependency_uid", *PACKAGE_URL_FIELDS, "project_id", *extra) |
3949 | 4047 |
|
3950 | 4048 |
|
3951 | 4049 | class DiscoveredDependency( |
|
0 commit comments