Skip to content

Commit 1e444a6

Browse files
authored
Add a new check-compliance management command #1346 (#1364)
* Add compliance_issues QuerySet method #1346 Signed-off-by: tdruez <[email protected]> * Add a new ``check-compliance`` management command #1346 Signed-off-by: tdruez <[email protected]> --------- Signed-off-by: tdruez <[email protected]>
1 parent 76ba322 commit 1e444a6

File tree

6 files changed

+212
-2
lines changed

6 files changed

+212
-2
lines changed

CHANGELOG.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@ v34.7.2 (unreleased)
4747
- Update link references of ownership from nexB to aboutcode-org
4848
https://github.com/aboutcode-org/scancode.io/issues/1350
4949

50+
- Add a new ``check-compliance`` management command to check for compliance issues in
51+
a project.
52+
https://github.com/nexB/scancode.io/issues/1182
53+
5054
v34.7.1 (2024-07-15)
5155
--------------------
5256

docs/command-line-interface.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,19 @@ Optional arguments:
264264
Refer to :ref:`Mount projects workspace <mount_projects_workspace_volume>` to access
265265
your outputs on the host machine when running with Docker.
266266

267+
`$ scanpipe check-compliance --project PROJECT`
268+
-----------------------------------------------
269+
270+
Check for compliance issues in Project.
271+
Exit with a non-zero status if compliance issues are present in the project.
272+
The compliance alert indicates how the license expression complies with provided
273+
policies.
274+
275+
Optional arguments:
276+
277+
- ``--fail-level {ERROR,WARNING,MISSING}`` Compliance alert level that will cause the
278+
command to exit with a non-zero status. Default is ERROR.
279+
267280
`$ scanpipe archive-project --project PROJECT`
268281
----------------------------------------------
269282

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
#
3+
# http://nexb.com and https://github.com/nexB/scancode.io
4+
# The ScanCode.io software is licensed under the Apache License version 2.0.
5+
# Data generated with ScanCode.io is provided as-is without warranties.
6+
# ScanCode is a trademark of nexB Inc.
7+
#
8+
# You may not use this software except in compliance with the License.
9+
# You may obtain a copy of the License at: http://apache.org/licenses/LICENSE-2.0
10+
# Unless required by applicable law or agreed to in writing, software distributed
11+
# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
12+
# CONDITIONS OF ANY KIND, either express or implied. See the License for the
13+
# specific language governing permissions and limitations under the License.
14+
#
15+
# Data Generated with ScanCode.io is provided on an "AS IS" BASIS, WITHOUT WARRANTIES
16+
# OR CONDITIONS OF ANY KIND, either express or implied. No content created from
17+
# ScanCode.io should be considered or used as legal advice. Consult an Attorney
18+
# for any legal advice.
19+
#
20+
# ScanCode.io is a free software code scanning tool from nexB Inc. and others.
21+
# Visit https://github.com/nexB/scancode.io for support and download.
22+
23+
import sys
24+
from collections import defaultdict
25+
26+
from scanpipe.management.commands import ProjectCommand
27+
from scanpipe.models import PACKAGE_URL_FIELDS
28+
29+
30+
class Command(ProjectCommand):
31+
help = (
32+
"Check for compliance issues in Project. Exit with a non-zero status if "
33+
"compliance issues are present in the project."
34+
"The compliance alert indicates how the license expression complies with "
35+
"provided policies."
36+
)
37+
38+
def add_arguments(self, parser):
39+
super().add_arguments(parser)
40+
parser.add_argument(
41+
"--fail-level",
42+
default="ERROR",
43+
choices=["ERROR", "WARNING", "MISSING"],
44+
help=(
45+
"Compliance alert level that will cause the command to exit with a "
46+
"non-zero status. Default is ERROR."
47+
),
48+
)
49+
50+
def handle(self, *args, **options):
51+
super().handle(*args, **options)
52+
fail_level = options["fail_level"]
53+
total_compliance_issues_count = 0
54+
55+
package_qs = self.project.discoveredpackages.compliance_issues(
56+
severity=fail_level
57+
).only(*PACKAGE_URL_FIELDS, "compliance_alert")
58+
59+
resource_qs = self.project.codebaseresources.compliance_issues(
60+
severity=fail_level
61+
).only("path", "compliance_alert")
62+
63+
queryset_mapping = {
64+
"Package": package_qs,
65+
"Resource": resource_qs,
66+
}
67+
68+
results = {}
69+
for label, queryset in queryset_mapping.items():
70+
compliance_issues = defaultdict(list)
71+
for instance in queryset:
72+
compliance_issues[instance.compliance_alert].append(str(instance))
73+
total_compliance_issues_count += 1
74+
if compliance_issues:
75+
results[label] = dict(compliance_issues)
76+
77+
if not total_compliance_issues_count:
78+
sys.exit(0)
79+
80+
if self.verbosity > 0:
81+
msg = [
82+
f"{total_compliance_issues_count} compliance issues detected on "
83+
f"this project."
84+
]
85+
for label, issues in results.items():
86+
msg.append(f"{label}:")
87+
for severity, entries in issues.items():
88+
msg.append(f" - {severity}: {len(entries)}")
89+
90+
self.stderr.write("\n".join(msg))
91+
92+
sys.exit(1)

scanpipe/models.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2061,6 +2061,33 @@ def profile(self, print_results=False):
20612061
print(output_str)
20622062

20632063

2064+
class ComplianceAlertQuerySetMixin:
2065+
def compliance_issues(self, severity):
2066+
"""
2067+
Retrieve compliance issues based on severity.
2068+
Supported values are 'error', 'warning', and 'missing'.
2069+
"""
2070+
compliance = self.model.Compliance
2071+
severity = severity.lower()
2072+
2073+
severity_mapping = {
2074+
"error": [compliance.ERROR.value],
2075+
"warning": [compliance.ERROR.value, compliance.WARNING.value],
2076+
"missing": [
2077+
compliance.ERROR.value,
2078+
compliance.WARNING.value,
2079+
compliance.MISSING.value,
2080+
],
2081+
}
2082+
2083+
if severity not in severity_mapping:
2084+
raise ValueError(
2085+
f"Supported severities are: {', '.join(severity_mapping.keys())}"
2086+
)
2087+
2088+
return self.filter(compliance_alert__in=severity_mapping[severity])
2089+
2090+
20642091
def convert_glob_to_django_regex(glob_pattern):
20652092
"""
20662093
Convert a glob pattern to an equivalent django regex pattern
@@ -2073,7 +2100,7 @@ def convert_glob_to_django_regex(glob_pattern):
20732100
return escaped_pattern
20742101

20752102

2076-
class CodebaseResourceQuerySet(ProjectRelatedQuerySet):
2103+
class CodebaseResourceQuerySet(ComplianceAlertQuerySetMixin, ProjectRelatedQuerySet):
20772104
def prefetch_for_serializer(self):
20782105
"""
20792106
Optimized prefetching for a QuerySet to be consumed by the
@@ -2965,7 +2992,10 @@ def vulnerable(self):
29652992

29662993

29672994
class DiscoveredPackageQuerySet(
2968-
VulnerabilityQuerySetMixin, PackageURLQuerySetMixin, ProjectRelatedQuerySet
2995+
VulnerabilityQuerySetMixin,
2996+
PackageURLQuerySetMixin,
2997+
ComplianceAlertQuerySetMixin,
2998+
ProjectRelatedQuerySet,
29692999
):
29703000
def with_resources_count(self):
29713001
count_subquery = Subquery(

scanpipe/tests/test_commands.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@
4444
from scanpipe.models import Run
4545
from scanpipe.models import WebhookSubscription
4646
from scanpipe.pipes import purldb
47+
from scanpipe.tests import make_package
48+
from scanpipe.tests import make_resource_file
4749

4850
scanpipe_app = apps.get_app_config("scanpipe")
4951

@@ -925,6 +927,52 @@ def test_scanpipe_management_command_purldb_scan_queue_worker_continue_after_fai
925927
mock_post_call2.kwargs["data"]["scan_log"],
926928
)
927929

930+
def test_scanpipe_management_command_check_compliance(self):
931+
project = Project.objects.create(name="my_project")
932+
933+
out = StringIO()
934+
options = ["--project", project.name]
935+
with self.assertRaises(SystemExit) as cm:
936+
call_command("check-compliance", *options, stdout=out)
937+
self.assertEqual(cm.exception.code, 0)
938+
out_value = out.getvalue().strip()
939+
self.assertEqual("", out_value)
940+
941+
make_resource_file(
942+
project,
943+
path="warning",
944+
compliance_alert=CodebaseResource.Compliance.WARNING,
945+
)
946+
make_package(
947+
project,
948+
package_url="pkg:generic/[email protected]",
949+
compliance_alert=CodebaseResource.Compliance.ERROR,
950+
)
951+
952+
out = StringIO()
953+
options = ["--project", project.name]
954+
with self.assertRaises(SystemExit) as cm:
955+
call_command("check-compliance", *options, stderr=out)
956+
self.assertEqual(cm.exception.code, 1)
957+
out_value = out.getvalue().strip()
958+
expected = (
959+
"1 compliance issues detected on this project." "\nPackage:\n - error: 1"
960+
)
961+
self.assertEqual(expected, out_value)
962+
963+
out = StringIO()
964+
options = ["--project", project.name, "--fail-level", "WARNING"]
965+
with self.assertRaises(SystemExit) as cm:
966+
call_command("check-compliance", *options, stderr=out)
967+
self.assertEqual(cm.exception.code, 1)
968+
out_value = out.getvalue().strip()
969+
expected = (
970+
"2 compliance issues detected on this project."
971+
"\nPackage:\n - error: 1"
972+
"\nResource:\n - warning: 1"
973+
)
974+
self.assertEqual(expected, out_value)
975+
928976

929977
class ScanPipeManagementCommandMixinTest(TestCase):
930978
class CreateProjectCommand(

scanpipe/tests/test_models.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2436,6 +2436,29 @@ def test_scanpipe_codebase_resource_queryset_elfs(self):
24362436
self.assertTrue("e" in paths)
24372437
self.assertTrue("a" in paths)
24382438

2439+
def test_scanpipe_model_codebase_resource_compliance_alert_queryset_mixin(self):
2440+
severities = CodebaseResource.Compliance
2441+
make_resource_file(self.project1, path="none")
2442+
make_resource_file(self.project1, path="ok", compliance_alert=severities.OK)
2443+
warning = make_resource_file(
2444+
self.project1, path="warning", compliance_alert=severities.WARNING
2445+
)
2446+
error = make_resource_file(
2447+
self.project1, path="error", compliance_alert=severities.ERROR
2448+
)
2449+
missing = make_resource_file(
2450+
self.project1, path="missing", compliance_alert=severities.MISSING
2451+
)
2452+
2453+
qs = CodebaseResource.objects.order_by("path")
2454+
self.assertQuerySetEqual(qs.compliance_issues(severities.ERROR), [error])
2455+
self.assertQuerySetEqual(
2456+
qs.compliance_issues(severities.WARNING), [error, warning]
2457+
)
2458+
self.assertQuerySetEqual(
2459+
qs.compliance_issues(severities.MISSING), [error, missing, warning]
2460+
)
2461+
24392462

24402463
class ScanPipeModelsTransactionTest(TransactionTestCase):
24412464
"""

0 commit comments

Comments
 (0)