diff --git a/docs/policies.rst b/docs/policies.rst index 0f2268832..783942d7a 100644 --- a/docs/policies.rst +++ b/docs/policies.rst @@ -54,6 +54,43 @@ structure similar to the following: - ``warning`` — Use with caution; the license may have some restrictions. - ``error`` — The license is prohibited or incompatible with your policy. +Creating Clarity Thresholds Files +--------------------------------- + +A valid clarity thresholds file is required to **enable license clarity compliance features**. + +The clarity thresholds file, by default named ``policies.yml``, is a **YAML file** with a +structure similar to the following: + +.. code-block:: yaml + + license_clarity_thresholds: + 91: ok + 80: warning + 0: error + +- In the example above, the keys ``91``, ``80``, and ``0`` are integer threshold values + representing **minimum clarity scores**. +- The values ``error``, ``warning``, and ``ok`` are the **compliance alert levels** that + will be triggered if the project's license clarity score meets or exceeds the + corresponding threshold. +- The thresholds must be listed in **strictly descending order**. + +How it works: + +- If the clarity score is **91 or above**, the alert is **``ok``**. +- If the clarity score is **80 to 90**, the alert is **``warning``**. +- If the clarity score is **below 80**, the alert is **``error``**. + +You can adjust the threshold values and alert levels to match your organization's +compliance requirements. + +Accepted values for the alert level: + +- ``ok`` +- ``warning`` +- ``error`` + App Policies ------------ @@ -115,7 +152,7 @@ REST API -------- For more details on retrieving compliance data through the REST API, see the -:ref:`rest_api_compliance` section. +:ref:`rest_api_compliance` section and :ref:`rest_api_clarity_compliance` section. Command Line Interface ---------------------- diff --git a/docs/rest-api.rst b/docs/rest-api.rst index 1e6dfe570..09c2a4145 100644 --- a/docs/rest-api.rst +++ b/docs/rest-api.rst @@ -493,6 +493,31 @@ Data: } } +.. _rest_api_clarity_compliance: + +License Clarity Compliance +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +This action returns the **license clarity compliance alert** for a project. + +The license clarity compliance alert is a single value (``ok``, ``warning``, or ``error``) +that summarizes the project's **license clarity status**, based on the thresholds defined in +the ``policies.yml`` file. + +``GET /api/projects/6461408c-726c-4b70-aa7a-c9cc9d1c9685/license_clarity_compliance/`` + +Data: + - ``license_clarity_compliance_alert``: The overall license clarity compliance alert + for the project. + + Possible values: ``ok``, ``warning``, ``error``. + +.. code-block:: json + + { + "license_clarity_compliance_alert": "warning" + } + Reset ^^^^^ diff --git a/docs/tutorial_license_policies.rst b/docs/tutorial_license_policies.rst index 9e0a6aea9..aa7ac717c 100644 --- a/docs/tutorial_license_policies.rst +++ b/docs/tutorial_license_policies.rst @@ -83,6 +83,51 @@ detected license, and computed at the codebase resource level, for example: "[...]": "[...]" } +License Clarity Thresholds and Compliance +----------------------------------------- + +ScanCode.io also supports **license clarity thresholds**, allowing you to enforce +minimum standards for license detection quality in your codebase. This is managed +through the ``license_clarity_thresholds`` section in your ``policies.yml`` file. + +Defining Clarity Thresholds +--------------------------- + +Add a ``license_clarity_thresholds`` section to your ``policies.yml`` file, for example: + +.. code-block:: yaml + + license_clarity_thresholds: + 91: ok + 80: warning + 0: error + + +License Clarity Compliance in Results +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When you run a pipeline with clarity thresholds defined in your ``policies.yml``, +the computed license clarity compliance alert is included in the project's ``extra_data`` field. + +For example: + +.. code-block:: json + + "extra_data": { + "md5": "d23df4a4", + "sha1": "3e9b61cc98c", + "size": 3095, + "sha256": "abacfc8bcee59067", + "sha512": "208f6a83c83a4c770b3c0", + "filename": "cuckoo_filter-1.0.6.tar.gz", + "sha1_git": "3fdb0f82ad59", + "license_clarity_compliance_alert": "error" + } + +The ``license_clarity_compliance_alert`` value (e.g., ``"error"``, ``"warning"``, or ``"ok"``) +is computed automatically based on the thresholds you configured and reflects the +overall license clarity status of the scanned codebase. + Run the ``check-compliance`` command ------------------------------------ @@ -95,7 +140,7 @@ in the project: .. code-block:: bash - 4 compliance issues detected on this project. + 5 compliance issues detected on this project. [packages] > ERROR: 3 pkg:pypi/cuckoo-filter@. @@ -104,6 +149,8 @@ in the project: [resources] > ERROR: 1 cuckoo_filter-1.0.6.tar.gz-extract/cuckoo_filter-1.0.6/README.md + [license clarity] + > Alert Level: ERROR .. tip:: In case of compliance alerts, the command returns a non-zero exit code which diff --git a/scanpipe/api/views.py b/scanpipe/api/views.py index 4870c21a3..b69796d98 100644 --- a/scanpipe/api/views.py +++ b/scanpipe/api/views.py @@ -481,6 +481,22 @@ def compliance(self, request, *args, **kwargs): compliance_alerts = get_project_compliance_alerts(project, fail_level) return Response({"compliance_alerts": compliance_alerts}) + @action(detail=True, methods=["get"]) + def clarity_compliance(self, request, *args, **kwargs): + """ + Retrieve the license clarity compliance alert for a project. + + This endpoint returns the license clarity compliance alert stored in the + project's extra_data. + + Example: + GET /api/projects/{project_id}/license_clarity_compliance/ + + """ + project = self.get_object() + clarity_alert = project.get_license_clarity_compliance_alert() + return Response({"license_clarity_compliance_alert": clarity_alert}) + class RunViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): """Add actions to the Run viewset.""" diff --git a/scanpipe/management/commands/check-compliance.py b/scanpipe/management/commands/check-compliance.py index 342b5b55e..41e340684 100644 --- a/scanpipe/management/commands/check-compliance.py +++ b/scanpipe/management/commands/check-compliance.py @@ -74,8 +74,13 @@ def check_compliance(self, fail_level): len(issues) for model in alerts.values() for issues in model.values() ) - if count and self.verbosity > 0: - self.stderr.write(f"{count} compliance issues detected.") + clarity_alert = self.project.get_license_clarity_compliance_alert() + has_clarity_issue = clarity_alert not in (None, "ok") + + total_issues = count + (1 if has_clarity_issue else 0) + + if total_issues and self.verbosity > 0: + self.stderr.write(f"{total_issues} compliance issues detected.") for label, model in alerts.items(): self.stderr.write(f"[{label}]") for severity, entries in model.items(): @@ -83,7 +88,11 @@ def check_compliance(self, fail_level): if self.verbosity > 1: self.stderr.write(" " + "\n ".join(entries)) - return count > 0 + if has_clarity_issue: + self.stderr.write("[license clarity]") + self.stderr.write(f" > Alert Level: {clarity_alert.upper()}") + + return total_issues > 0 def check_vulnerabilities(self): packages = self.project.discoveredpackages.vulnerable_ordered() diff --git a/scanpipe/models.py b/scanpipe/models.py index fd72fe711..aeb187430 100644 --- a/scanpipe/models.py +++ b/scanpipe/models.py @@ -1514,6 +1514,14 @@ def get_policies_dict(self): return scanpipe_app.policies + def get_license_clarity_compliance_alert(self): + """ + Return the license clarity compliance alert value for the project, + or None if not set. + """ + extra_data = self.extra_data or {} + return extra_data.get("license_clarity_compliance_alert") + def get_license_policy_index(self): """Return the policy license index for this project instance.""" if policies_dict := self.get_policies_dict(): diff --git a/scanpipe/pipes/license_clarity.py b/scanpipe/pipes/license_clarity.py index 0f230976d..859e2e393 100644 --- a/scanpipe/pipes/license_clarity.py +++ b/scanpipe/pipes/license_clarity.py @@ -163,3 +163,22 @@ def load_clarity_thresholds_from_file(file_path): return load_clarity_thresholds_from_yaml(yaml_content) except (OSError, UnicodeDecodeError) as e: raise ValidationError(f"Error reading file {file_path}: {e}") + + +def get_project_clarity_thresholds(project): + """ + Get clarity thresholds for a project using the unified policy loading logic. + + Returns: + ClarityThresholdsPolicy or None: Policy object if thresholds are configured + + """ + policies_dict = project.get_policies_dict() + if not policies_dict: + return None + + clarity_thresholds = policies_dict.get("license_clarity_thresholds") + if not clarity_thresholds: + return None + + return ClarityThresholdsPolicy(clarity_thresholds) diff --git a/scanpipe/pipes/scancode.py b/scanpipe/pipes/scancode.py index f3501c9ec..12d3d3272 100644 --- a/scanpipe/pipes/scancode.py +++ b/scanpipe/pipes/scancode.py @@ -52,6 +52,7 @@ from scanpipe.models import DiscoveredDependency from scanpipe.models import DiscoveredPackage from scanpipe.pipes import flag +from scanpipe.pipes.license_clarity import get_project_clarity_thresholds logger = logging.getLogger("scanpipe.pipes") @@ -931,7 +932,10 @@ def make_results_summary(project, scan_results_location): Extract selected sections of the Scan results, such as the `summary` `license_clarity_score`, and `license_matches` related data. The `key_files` are also collected and injected in the `summary` output. + Additionally, store license_clarity_compliance_alert in project's extra_data. """ + import json + from scanpipe.api.serializers import CodebaseResourceSerializer from scanpipe.api.serializers import DiscoveredPackageSerializer @@ -964,4 +968,12 @@ def make_results_summary(project, scan_results_location): DiscoveredPackageSerializer(package).data for package in key_files_packages_qs ] + clarity_score = summary.get("license_clarity_score", {}).get("score") + if clarity_score is not None: + clarity_policy = get_project_clarity_thresholds(project) + if clarity_policy: + alert = clarity_policy.get_alert_for_score(clarity_score) + summary["license_clarity_compliance_alert"] = alert + + project.update_extra_data({"license_clarity_compliance_alert": alert}) return summary diff --git a/scanpipe/templates/scanpipe/panels/project_compliance.html b/scanpipe/templates/scanpipe/panels/project_compliance.html index 167ea1c08..c1f0967b2 100644 --- a/scanpipe/templates/scanpipe/panels/project_compliance.html +++ b/scanpipe/templates/scanpipe/panels/project_compliance.html @@ -1,11 +1,11 @@ {% load humanize %} -{% if compliance_alerts %} +{% if compliance_alerts or license_clarity_compliance_alert %}
-{% endif %} \ No newline at end of file +{% endif %} diff --git a/scanpipe/tests/test_api.py b/scanpipe/tests/test_api.py index 71cbe1f8d..ef621b79a 100644 --- a/scanpipe/tests/test_api.py +++ b/scanpipe/tests/test_api.py @@ -1252,6 +1252,24 @@ def test_scanpipe_api_project_action_compliance(self): } self.assertDictEqual(expected, response.data) + def test_scanpipe_api_project_action_license_clarity_compliance(self): + project = make_project() + url = reverse("project-clarity-compliance", args=[project.uuid]) + + response = self.csrf_client.get(url) + expected = {"license_clarity_compliance_alert": None} + self.assertEqual(expected, response.data) + + project.update_extra_data({"license_clarity_compliance_alert": "ok"}) + response = self.csrf_client.get(url) + expected = {"license_clarity_compliance_alert": "ok"} + self.assertEqual(expected, response.data) + + project.update_extra_data({"license_clarity_compliance_alert": "error"}) + response = self.csrf_client.get(url) + expected = {"license_clarity_compliance_alert": "error"} + self.assertEqual(expected, response.data) + def test_scanpipe_api_serializer_get_model_serializer(self): self.assertEqual( DiscoveredPackageSerializer, get_model_serializer(DiscoveredPackage) diff --git a/scanpipe/tests/test_commands.py b/scanpipe/tests/test_commands.py index 6474f1e59..0c9d03cda 100644 --- a/scanpipe/tests/test_commands.py +++ b/scanpipe/tests/test_commands.py @@ -1213,6 +1213,47 @@ def test_scanpipe_management_command_check_compliance(self): ) self.assertEqual(expected, out_value) + def test_scanpipe_management_command_check_clarity_compliance_only(self): + project = make_project(name="my_project_clarity") + + project.extra_data = {"license_clarity_compliance_alert": "error"} + project.save(update_fields=["extra_data"]) + + out = StringIO() + options = ["--project", project.name] + with self.assertRaises(SystemExit) as cm: + call_command("check-compliance", *options, stderr=out) + self.assertEqual(cm.exception.code, 1) + out_value = out.getvalue().strip() + expected = ( + "1 compliance issues detected.\n[license clarity]\n > Alert Level: ERROR" + ) + self.assertEqual(expected, out_value) + + def test_scanpipe_management_command_check_both_compliance_and_clarity(self): + project = make_project(name="my_project_both") + + make_package( + project, + package_url="pkg:generic/name@1.0", + compliance_alert=CodebaseResource.Compliance.ERROR, + ) + project.extra_data = {"license_clarity_compliance_alert": "warning"} + project.save(update_fields=["extra_data"]) + + out = StringIO() + options = ["--project", project.name, "--fail-level", "WARNING"] + with self.assertRaises(SystemExit) as cm: + call_command("check-compliance", *options, stderr=out) + self.assertEqual(cm.exception.code, 1) + out_value = out.getvalue().strip() + expected = ( + "2 compliance issues detected." + "\n[packages]\n > ERROR: 1" + "\n[license clarity]\n > Alert Level: WARNING" + ) + self.assertEqual(expected, out_value) + def test_scanpipe_management_command_check_compliance_vulnerabilities(self): project = make_project(name="my_project") package1 = make_package(project, package_url="pkg:generic/name@1.0") diff --git a/scanpipe/tests/test_views.py b/scanpipe/tests/test_views.py index 4c06995b1..518968961 100644 --- a/scanpipe/tests/test_views.py +++ b/scanpipe/tests/test_views.py @@ -1003,6 +1003,9 @@ def test_scanpipe_views_project_compliance_panel_view( compliance_alert=DiscoveredPackage.Compliance.ERROR, ) + self.project1.extra_data = {"license_clarity_compliance_alert": "warning"} + self.project1.save(update_fields=["extra_data"]) + mock_license_policies_enabled.return_value = False response = self.client.get(url) self.assertEqual(404, response.status_code) @@ -1011,6 +1014,8 @@ def test_scanpipe_views_project_compliance_panel_view( response = self.client.get(url) self.assertContains(response, "Compliance alerts") self.assertContains(response, "1 Error") + self.assertContains(response, "License clarity") + self.assertContains(response, "Warning") expected = f"/project/{self.project1.slug}/packages/?compliance_alert=error" self.assertContains(response, expected) diff --git a/scanpipe/views.py b/scanpipe/views.py index d630628fb..4e46dfeb9 100644 --- a/scanpipe/views.py +++ b/scanpipe/views.py @@ -1209,6 +1209,12 @@ def get_context_data(self, **kwargs): fail_level="missing", ) context["compliance_alerts"] = compliance_alerts + + extra_data = project.extra_data or {} + context["license_clarity_compliance_alert"] = extra_data.get( + "license_clarity_compliance_alert" + ) + return context