Skip to content

Commit 2565167

Browse files
authored
Add --vulnerabilities and --strict options in verify-project (#1964)
Signed-off-by: tdruez <tdruez@aboutcode.org>
1 parent 4b92916 commit 2565167

File tree

5 files changed

+284
-70
lines changed

5 files changed

+284
-70
lines changed

docs/command-line-interface.rst

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -803,29 +803,50 @@ See the :ref:`cli_output` for more information about supported output formats.
803803

804804
Verifies the analysis results of a project against expected package and dependency
805805
counts.
806-
This command is designed to ensure that a projects scan results meet specific
806+
This command is designed to ensure that a project's scan results meet specific
807807
expectations — for example, that a minimum number of packages or dependencies were
808-
discovered, and that no unexpected vulnerabilities were introduced.
808+
discovered, or that vulnerability counts match expected baselines.
809809

810810
Optional arguments:
811811

812-
- ``--packages`` Minimum number of discovered packages expected.
812+
- ``--packages`` Expected number of discovered packages.
813813

814-
- ``--vulnerable-packages`` Minimum number of vulnerable packages expected.
814+
- ``--vulnerable-packages`` Expected number of vulnerable packages.
815815

816-
- ``--dependencies`` Minimum number of discovered dependencies expected.
816+
- ``--dependencies`` Expected number of discovered dependencies.
817817

818-
- ``--vulnerable-dependencies`` Minimum number of vulnerable dependencies expected.
818+
- ``--vulnerable-dependencies`` Expected number of vulnerable dependencies.
819819

820-
If any of these expectations are not met, the command exits with a non-zero status
821-
and prints a summary of all issues found.
820+
- ``--vulnerabilities`` Expected number of unique vulnerabilities.
821+
Combines vulnerabilities from both packages and dependencies.
822+
823+
- ``--strict`` Assert on strict count equality instead of minimum threshold.
824+
When not provided, the command checks that actual counts are at least the expected
825+
values (greater than or equal). With ``--strict``, actual counts must match expected
826+
values exactly.
827+
828+
By default, the command verifies that actual counts meet or exceed the expected values.
829+
Only the metrics explicitly provided via command-line arguments are validated.
830+
831+
If any expectations are not met, the command exits with a non-zero status and prints
832+
a summary of all issues found.
822833

823834
Example usage:
824835

825-
.. code-block:: bash
836+
1. Verify minimum thresholds (default behavior)::
837+
838+
$ scanpipe verify-project --project my_project --packages 100 --dependencies 50
839+
840+
2. Verify exact counts with strict mode::
841+
842+
$ scanpipe verify-project --project my_project --vulnerabilities 14 --strict
843+
844+
3. Verify only specific metrics::
826845

827-
$ scanpipe verify-project --project my_project --packages 100 --dependencies 50
846+
$ scanpipe verify-project --project my_project --vulnerable-packages 5
828847

829848
.. tip::
830849
This command is particularly useful for **CI/CD pipelines** that need to validate
831-
SBOM or vulnerability scan results against known baselines.
850+
SBOM or vulnerability scan results against known baselines. Use non-strict mode
851+
to ensure minimum quality thresholds, or strict mode to detect unexpected changes
852+
in scan results.

scanpipe/management/commands/verify-project.py

Lines changed: 75 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -33,26 +33,40 @@ def add_arguments(self, parser):
3333
parser.add_argument(
3434
"--packages",
3535
type=int,
36-
default=0,
37-
help="Minimum number of packages expected (default: 0)",
36+
default=None,
37+
help="Expected number of packages",
3838
)
3939
parser.add_argument(
4040
"--vulnerable-packages",
4141
type=int,
42-
default=0,
43-
help="Minimum number of vulnerable packages expected (default: 0)",
42+
default=None,
43+
help="Expected number of vulnerable packages",
4444
)
4545
parser.add_argument(
4646
"--dependencies",
4747
type=int,
48-
default=0,
49-
help="Minimum number of dependencies expected (default: 0)",
48+
default=None,
49+
help="Expected number of dependencies",
5050
)
5151
parser.add_argument(
5252
"--vulnerable-dependencies",
5353
type=int,
54-
default=0,
55-
help="Minimum number of vulnerable dependencies expected (default: 0)",
54+
default=None,
55+
help="Expected number of vulnerable dependencies",
56+
)
57+
parser.add_argument(
58+
"--vulnerabilities",
59+
type=int,
60+
default=None,
61+
help=(
62+
"Expected number of unique vulnerabilities. "
63+
"Combines vulnerabilities from both packages and dependencies"
64+
),
65+
)
66+
parser.add_argument(
67+
"--strict",
68+
action="store_true",
69+
help="Assert on strict count equality instead of minimum threshold",
5670
)
5771

5872
def handle(self, *args, **options):
@@ -62,38 +76,66 @@ def handle(self, *args, **options):
6276
expected_vulnerable_packages = options["vulnerable_packages"]
6377
expected_dependencies = options["dependencies"]
6478
expected_vulnerable_dependencies = options["vulnerable_dependencies"]
79+
expected_vulnerabilities = options["vulnerabilities"]
80+
strict = options["strict"]
6581

6682
project = self.project
6783
packages = project.discoveredpackages
68-
package_count = packages.count()
69-
vulnerable_package_count = packages.vulnerable().count()
70-
dependencies = project.discovereddependencies.all()
71-
dependency_count = dependencies.count()
72-
vulnerable_dependency_count = dependencies.vulnerable().count()
84+
dependencies = project.discovereddependencies
85+
vulnerabilities = project.vulnerabilities
7386

74-
errors = []
87+
# Check all counts (only if expected value is provided)
88+
checks = [
89+
(
90+
packages.count(),
91+
expected_packages,
92+
"packages",
93+
),
94+
(
95+
packages.vulnerable().count(),
96+
expected_vulnerable_packages,
97+
"vulnerable packages",
98+
),
99+
(
100+
dependencies.count(),
101+
expected_dependencies,
102+
"dependencies",
103+
),
104+
(
105+
dependencies.vulnerable().count(),
106+
expected_vulnerable_dependencies,
107+
"vulnerable dependencies",
108+
),
109+
(
110+
len(vulnerabilities),
111+
expected_vulnerabilities,
112+
"vulnerabilities on the project",
113+
),
114+
]
75115

76-
if package_count < expected_packages:
77-
errors.append(
78-
f"Expected at least {expected_packages} packages, found {package_count}"
79-
)
80-
if vulnerable_package_count < expected_vulnerable_packages:
81-
errors.append(
82-
f"Expected at least {expected_vulnerable_packages} vulnerable packages,"
83-
f" found {vulnerable_package_count}"
84-
)
85-
if dependency_count < expected_dependencies:
86-
errors.append(
87-
f"Expected at least {expected_dependencies} dependencies, "
88-
f"found {dependency_count}"
89-
)
90-
if vulnerable_dependency_count < expected_vulnerable_dependencies:
91-
errors.append(
92-
f"Expected at least {expected_vulnerable_dependencies} "
93-
f"vulnerable dependencies, found {vulnerable_dependency_count}"
94-
)
116+
errors = []
117+
for actual, expected, label in checks:
118+
if expected is not None: # Only check if value was provided
119+
if error := self.check_count(actual, expected, label, strict):
120+
errors.append(error)
95121

96122
if errors:
97123
raise CommandError("Project verification failed:\n" + "\n".join(errors))
98124

99125
self.stdout.write("Project verification passed.", self.style.SUCCESS)
126+
127+
@staticmethod
128+
def check_count(actual, expected, label, strict):
129+
"""
130+
Check if actual count meets expectations.
131+
132+
In strict mode, checks for exact equality.
133+
Otherwise, checks if actual is at least the expected value.
134+
135+
Returns an error message string if check fails.
136+
"""
137+
if strict and actual != expected:
138+
return f"Expected exactly {expected} {label}, found {actual}"
139+
140+
if not strict and actual < expected:
141+
return f"Expected at least {expected} {label}, found {actual}"

scanpipe/models.py

Lines changed: 104 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from collections import Counter
3030
from collections import defaultdict
3131
from contextlib import suppress
32+
from itertools import chain
3233
from itertools import groupby
3334
from operator import itemgetter
3435
from pathlib import Path
@@ -1486,12 +1487,12 @@ def package_count(self):
14861487
@cached_property
14871488
def vulnerable_package_count(self):
14881489
"""Return the number of vulnerable packages related to this project."""
1489-
return self.discoveredpackages.vulnerable().count()
1490+
return self.vulnerable_packages.count()
14901491

14911492
@cached_property
14921493
def vulnerable_dependency_count(self):
14931494
"""Return the number of vulnerable dependencies related to this project."""
1494-
return self.discovereddependencies.vulnerable().count()
1495+
return self.vulnerable_dependencies.count()
14951496

14961497
@cached_property
14971498
def dependency_count(self):
@@ -1537,6 +1538,49 @@ def relation_count(self):
15371538
"""Return the number of relations related to this project."""
15381539
return self.codebaserelations.count()
15391540

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+
15401584
@cached_property
15411585
def has_single_resource(self):
15421586
"""
@@ -3284,16 +3328,70 @@ def is_vulnerable(self):
32843328

32853329

32863330
class VulnerabilityQuerySetMixin:
3331+
AFFECTED_BY_FIELD = "affected_by_vulnerabilities"
3332+
32873333
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}))
32893335

32903336
def vulnerable_ordered(self):
32913337
return (
32923338
self.vulnerable()
3293-
.only_package_url_fields(extra=["affected_by_vulnerabilities"])
3339+
.only_package_url_fields(extra=[self.AFFECTED_BY_FIELD])
32943340
.order_by_package_url()
32953341
)
32963342

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+
32973395

32983396
class DiscoveredPackageQuerySet(
32993397
VulnerabilityQuerySetMixin,
@@ -3324,7 +3422,7 @@ def only_package_url_fields(self, extra=None):
33243422
if not extra:
33253423
extra = []
33263424

3327-
return self.only("uuid", *PACKAGE_URL_FIELDS, *extra)
3425+
return self.only("uuid", *PACKAGE_URL_FIELDS, "project_id", *extra)
33283426

33293427
def filter(self, *args, **kwargs):
33303428
"""Add support for using ``package_url`` as a field lookup."""
@@ -3945,7 +4043,7 @@ def only_package_url_fields(self, extra=None):
39454043
if not extra:
39464044
extra = []
39474045

3948-
return self.only("dependency_uid", *PACKAGE_URL_FIELDS, *extra)
4046+
return self.only("dependency_uid", *PACKAGE_URL_FIELDS, "project_id", *extra)
39494047

39504048

39514049
class DiscoveredDependency(

0 commit comments

Comments
 (0)