diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 574d596bfa..cfc5d3d2d6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -3,6 +3,13 @@ Changelog v35.2.0 (2025-08-01) -------------------- +- Enhanced scorecard compliance support with: + * New ``scorecard_compliance_alert`` in project ``extra_data``. + * ``/api/projects/{id}/scorecard_compliance/`` API endpoint. + * Scorecard compliance integration in ``check-compliance`` management command. + * UI template support for scorecard compliance alert. + * ``evaluate_scorecard_compliance()`` pipe function for compliance evaluation. + https://github.com/aboutcode-org/scancode.io/pull/1800 - Refactor policies implementation to support more than licenses. The entire ``policies`` data is now stored on the ``ScanPipeConfig`` in place of the diff --git a/docs/policies.rst b/docs/policies.rst index 31d36a553c..c53a369153 100644 --- a/docs/policies.rst +++ b/docs/policies.rst @@ -91,6 +91,43 @@ Accepted values for the alert level: - ``warning`` - ``error`` +Creating Scorecard Thresholds Files +----------------------------------- + +A valid scorecard thresholds file is required to **enable OpenSSF Scorecard compliance features**. + +The scorecard thresholds file, by default named ``policies.yml``, is a **YAML file** with a +structure similar to the following: + +.. code-block:: yaml + + scorecard_score_thresholds: + 9.0: ok + 7.0: warning + 0: error + +- In the example above, the keys ``9.0``, ``7.0``, and ``0`` are numeric threshold values + representing **minimum scorecard scores**. +- The values ``error``, ``warning``, and ``ok`` are the **compliance alert levels** that + will be triggered if the project's scorecard score meets or exceeds the + corresponding threshold. +- The thresholds must be listed in **strictly descending order**. + +How it works: + +- If the scorecard score is **9.0 or above**, the alert is **``ok``**. +- If the scorecard score is **7.0 to 8.9**, the alert is **``warning``**. +- If the scorecard score is **below 7.0**, the alert is **``error``**. + +You can adjust the threshold values and alert levels to match your organization's +security compliance requirements. + +Accepted values for the alert level: + +- ``ok`` +- ``warning`` +- ``error`` + App Policies ------------ diff --git a/docs/rest-api.rst b/docs/rest-api.rst index f83c6e6451..84e9068053 100644 --- a/docs/rest-api.rst +++ b/docs/rest-api.rst @@ -518,6 +518,31 @@ Data: "license_clarity_compliance_alert": "warning" } +.. _rest_api_scorecard_compliance: + +Scorecard Compliance +^^^^^^^^^^^^^^^^^^^^ + +This action returns the **scorecard compliance alert** for a project. + +The scorecard compliance alert is a single value (``ok``, ``warning``, or ``error``) +that summarizes the project's **OpenSSF Scorecard security compliance status**, +based on the thresholds defined in the ``policies.yml`` file. + +``GET /api/projects/6461408c-726c-4b70-aa7a-c9cc9d1c9685/scorecard_compliance/`` + +Data: + - ``scorecard_compliance_alert``: The overall scorecard compliance alert + for the project. + + Possible values: ``ok``, ``warning``, ``error``. + +.. code-block:: json + + { + "scorecard_compliance_alert": "warning" + } + Reset ^^^^^ diff --git a/docs/tutorial_license_policies.rst b/docs/tutorial_license_policies.rst index f53da66232..ceec7e3fef 100644 --- a/docs/tutorial_license_policies.rst +++ b/docs/tutorial_license_policies.rst @@ -128,6 +128,51 @@ The ``license_clarity_compliance_alert`` value (e.g., ``"error"``, ``"warning"`` is computed automatically based on the thresholds you configured and reflects the overall license clarity status of the scanned codebase. +Scorecard Compliance Thresholds and Alerts +------------------------------------------ + +ScanCode.io also supports **OpenSSF Scorecard compliance thresholds**, allowing you to enforce +minimum security standards for open source packages in your codebase. This is managed +through the ``scorecard_score_thresholds`` section in your ``policies.yml`` file. + +Defining Scorecard Thresholds +----------------------------- + +Add a ``scorecard_score_thresholds`` section to your ``policies.yml`` file, for example: + +.. code-block:: yaml + + scorecard_score_thresholds: + 9.0: ok + 7.0: warning + 0: error + +Scorecard Compliance in Results +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +When you run a the addon pipeline fetch_scores with scorecard thresholds defined in your +``policies.yml``, the computed scorecard 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", + "scorecard_compliance_alert": "warning" + } + +The ``scorecard_compliance_alert`` value (e.g., ``"error"``, ``"warning"``, or ``"ok"``) +is computed automatically based on the thresholds you configured and reflects the +overall security compliance status of the OpenSSF Scorecard scores for packages in the scanned codebase. + Run the ``check-compliance`` command ------------------------------------ diff --git a/scanpipe/api/views.py b/scanpipe/api/views.py index 0c2baa6f8d..d5d91189cb 100644 --- a/scanpipe/api/views.py +++ b/scanpipe/api/views.py @@ -497,6 +497,22 @@ def license_clarity_compliance(self, request, *args, **kwargs): clarity_alert = project.get_license_clarity_compliance_alert() return Response({"license_clarity_compliance_alert": clarity_alert}) + @action(detail=True, methods=["get"]) + def scorecard_compliance(self, request, *args, **kwargs): + """ + Retrieve the scorecard compliance alert for a project. + + This endpoint returns the scorecard compliance alert stored in the + project's extra_data. + + Example: + GET /api/projects/{project_id}/scorecard_compliance/ + + """ + project = self.get_object() + scorecard_alert = project.get_scorecard_compliance_alert() + return Response({"scorecard_compliance_alert": scorecard_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 f651c04f8a..216a849e67 100644 --- a/scanpipe/management/commands/check-compliance.py +++ b/scanpipe/management/commands/check-compliance.py @@ -77,7 +77,12 @@ def check_compliance(self, fail_level): 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) + scorecard_alert = self.project.get_scorecard_compliance_alert() + has_scorecard_issue = scorecard_alert not in (None, "ok") + + total_issues = ( + count + (1 if has_clarity_issue else 0) + (1 if has_scorecard_issue else 0) + ) if total_issues and self.verbosity > 0: self.stderr.write(f"{total_issues} compliance issues detected.") @@ -92,6 +97,10 @@ def check_compliance(self, fail_level): self.stderr.write("[license clarity]") self.stderr.write(f" > {clarity_alert.upper()}") + if has_scorecard_issue: + self.stderr.write("[scorecard compliance]") + self.stderr.write(f" > {scorecard_alert.upper()}") + return total_issues > 0 def check_vulnerabilities(self): diff --git a/scanpipe/models.py b/scanpipe/models.py index 0ecd8e6392..c9aa0637ac 100644 --- a/scanpipe/models.py +++ b/scanpipe/models.py @@ -1553,6 +1553,13 @@ def get_license_clarity_compliance_alert(self): """ return self.extra_data.get("license_clarity_compliance_alert") + def get_scorecard_compliance_alert(self): + """ + Return the scorecard compliance alert value for the project, + or None if not set. + """ + return self.extra_data.get("scorecard_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/pipelines/fetch_scores.py b/scanpipe/pipelines/fetch_scores.py index 24b1eaf997..63186282a7 100644 --- a/scanpipe/pipelines/fetch_scores.py +++ b/scanpipe/pipelines/fetch_scores.py @@ -25,6 +25,7 @@ from scanpipe.models import DiscoveredPackageScore from scanpipe.pipelines import Pipeline +from scanpipe.pipes import scorecard_compliance class FetchScores(Pipeline): @@ -49,6 +50,7 @@ def steps(cls): return ( cls.check_scorecode_service_availability, cls.fetch_packages_scorecode_info, + cls.evaluate_compliance_alerts, ) def check_scorecode_service_availability(self): @@ -64,3 +66,7 @@ def fetch_packages_scorecode_info(self): scorecard_data=scorecard_data, package=package, ) + + def evaluate_compliance_alerts(self): + """Evaluate scorecard compliance alerts for the project.""" + scorecard_compliance.evaluate_scorecard_compliance(self.project) diff --git a/scanpipe/pipes/scorecard_compliance.py b/scanpipe/pipes/scorecard_compliance.py new file mode 100644 index 0000000000..d108000391 --- /dev/null +++ b/scanpipe/pipes/scorecard_compliance.py @@ -0,0 +1,64 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# http://nexb.com and https://github.com/aboutcode-org/scancode.io +# The ScanCode.io software is licensed under the Apache License version 2.0. +# Data generated with ScanCode.io is provided as-is without warranties. +# ScanCode is a trademark of nexB Inc. +# +# You may not use this software except in compliance with the License. +# You may obtain a copy of the License at: http://apache.org/licenses/LICENSE-2.0 +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. +# +# Data Generated with ScanCode.io is provided on an "AS IS" BASIS, WITHOUT WARRANTIES +# OR CONDITIONS OF ANY KIND, either express or implied. No content created from +# ScanCode.io should be considered or used as legal advice. Consult an Attorney +# for any legal advice. +# +# ScanCode.io is a free software code scanning tool from nexB Inc. and others. +# Visit https://github.com/aboutcode-org/scancode.io for support and download. + +from scanpipe.pipes.compliance_thresholds import get_project_scorecard_thresholds + + +def evaluate_scorecard_compliance(project): + """ + Evaluate scorecard compliance for all discovered packages in the project. + + This function checks OpenSSF Scorecard scores against project-defined + thresholds and determines the worst compliance alert level across all packages. + Updates the project's extra_data with the overall compliance status. + """ + scorecard_policy = get_project_scorecard_thresholds(project) + if not scorecard_policy: + return + + worst_alert = None + packages_with_scores = project.discoveredpackages.filter( + scores__scoring_tool="ossf-scorecard" + ).distinct() + + for package in packages_with_scores: + latest_score = ( + package.scores.filter(scoring_tool="ossf-scorecard") + .order_by("-score_date") + .first() + ) + + if not latest_score or latest_score.score is None: + continue + + try: + score = float(latest_score.score) + alert = scorecard_policy.get_alert_for_score(score) + except Exception: + alert = "error" + + order = {"ok": 0, "warning": 1, "error": 2} + if worst_alert is None or order[alert] > order.get(worst_alert, -1): + worst_alert = alert + + if worst_alert is not None: + project.update_extra_data({"scorecard_compliance_alert": worst_alert}) diff --git a/scanpipe/templates/scanpipe/panels/project_compliance.html b/scanpipe/templates/scanpipe/panels/project_compliance.html index c1f0967b2a..194e3b0f5a 100644 --- a/scanpipe/templates/scanpipe/panels/project_compliance.html +++ b/scanpipe/templates/scanpipe/panels/project_compliance.html @@ -1,5 +1,5 @@ {% load humanize %} -{% if compliance_alerts or license_clarity_compliance_alert %} +{% if compliance_alerts or license_clarity_compliance_alert or scorecard_compliance_alert %}
{% endif %} + {% if scorecard_compliance_alert %} +
+ + Scorecard compliance + + + {{ scorecard_compliance_alert|title }} + +
+ {% endif %} {% endif %} diff --git a/scanpipe/tests/pipes/test_scorecard_compliance.py b/scanpipe/tests/pipes/test_scorecard_compliance.py new file mode 100644 index 0000000000..48cd72843c --- /dev/null +++ b/scanpipe/tests/pipes/test_scorecard_compliance.py @@ -0,0 +1,102 @@ +from unittest.mock import patch + +from django.test import TestCase +from django.utils import timezone + +from scanpipe.models import DiscoveredPackage +from scanpipe.models import DiscoveredPackageScore +from scanpipe.models import Project +from scanpipe.pipes.compliance_thresholds import ScorecardThresholdsPolicy +from scanpipe.pipes.scorecard_compliance import evaluate_scorecard_compliance + + +class EvaluateScorecardComplianceTest(TestCase): + """Test evaluate_scorecard_compliance pipe function.""" + + def setUp(self): + self.project = Project.objects.create(name="test_project") + + def create_package_with_score(self, package_name, score_value): + """Create a package with a scorecard score.""" + package = DiscoveredPackage.objects.create( + project=self.project, + name=package_name, + type="pypi", + vcs_url="https://github.com/numpy/numpy", + ) + + DiscoveredPackageScore.objects.create( + discovered_package=package, + scoring_tool="ossf-scorecard", + score=str(score_value), + scoring_tool_version="v4.10.2", + score_date=timezone.now(), + ) + + return package + + def test_no_scorecard_policy_configured(self): + """Test that function returns early when no scorecard policy is configured.""" + with patch( + "scanpipe.pipes.scorecard_compliance.get_project_scorecard_thresholds" + ) as mock_get_policy: + mock_get_policy.return_value = None + + evaluate_scorecard_compliance(self.project) + + self.project.refresh_from_db() + self.assertNotIn("scorecard_compliance_alert", self.project.extra_data) + + def test_sets_compliance_alert_correctly(self): + """Test that compliance alert is set correctly based on scores.""" + thresholds = {9.0: "ok", 7.0: "warning", 0: "error"} + policy = ScorecardThresholdsPolicy(thresholds) + + self.create_package_with_score("good_package", 9.5) + + with patch( + "scanpipe.pipes.scorecard_compliance.get_project_scorecard_thresholds" + ) as mock_get_policy: + mock_get_policy.return_value = policy + + evaluate_scorecard_compliance(self.project) + + self.project.refresh_from_db() + self.assertEqual( + self.project.extra_data["scorecard_compliance_alert"], "ok" + ) + + def test_worst_alert_selected_for_multiple_packages(self): + """Test that worst alert level is selected across multiple packages.""" + thresholds = {9.0: "ok", 7.0: "warning", 0: "error"} + policy = ScorecardThresholdsPolicy(thresholds) + + self.create_package_with_score("good_package", 9.5) + self.create_package_with_score("bad_package", 5.0) + + with patch( + "scanpipe.pipes.scorecard_compliance.get_project_scorecard_thresholds" + ) as mock_get_policy: + mock_get_policy.return_value = policy + + evaluate_scorecard_compliance(self.project) + + self.project.refresh_from_db() + self.assertEqual( + self.project.extra_data["scorecard_compliance_alert"], "error" + ) + + def test_no_packages_with_scores(self): + """Test behavior when no packages have scorecard scores.""" + thresholds = {9.0: "ok", 7.0: "warning", 0: "error"} + policy = ScorecardThresholdsPolicy(thresholds) + + with patch( + "scanpipe.pipes.scorecard_compliance.get_project_scorecard_thresholds" + ) as mock_get_policy: + mock_get_policy.return_value = policy + + evaluate_scorecard_compliance(self.project) + + self.project.refresh_from_db() + self.assertNotIn("scorecard_compliance_alert", self.project.extra_data) diff --git a/scanpipe/tests/test_api.py b/scanpipe/tests/test_api.py index 61c1d5891b..41ab650aaf 100644 --- a/scanpipe/tests/test_api.py +++ b/scanpipe/tests/test_api.py @@ -1270,6 +1270,24 @@ def test_scanpipe_api_project_action_license_clarity_compliance(self): expected = {"license_clarity_compliance_alert": "error"} self.assertEqual(expected, response.data) + def test_scanpipe_api_project_action_scorecard_compliance(self): + project = make_project() + url = reverse("project-scorecard-compliance", args=[project.uuid]) + + response = self.csrf_client.get(url) + expected = {"scorecard_compliance_alert": None} + self.assertEqual(expected, response.data) + + project.update_extra_data({"scorecard_compliance_alert": "ok"}) + response = self.csrf_client.get(url) + expected = {"scorecard_compliance_alert": "ok"} + self.assertEqual(expected, response.data) + + project.update_extra_data({"scorecard_compliance_alert": "error"}) + response = self.csrf_client.get(url) + expected = {"scorecard_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 6d6ec8a000..1b2d6f688e 100644 --- a/scanpipe/tests/test_commands.py +++ b/scanpipe/tests/test_commands.py @@ -1252,6 +1252,49 @@ def test_scanpipe_management_command_check_both_compliance_and_clarity(self): ) self.assertEqual(expected, out_value) + def test_scanpipe_management_command_check_scorecard_compliance_only(self): + project = make_project(name="my_project_scorecard") + + project.extra_data = {"scorecard_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[scorecard compliance]\n > ERROR" + self.assertEqual(expected, out_value) + + def test_scanpipe_management_command_check_all_compliance_types(self): + project = make_project(name="my_project_all") + + make_package( + project, + package_url="pkg:generic/name@1.0", + compliance_alert=CodebaseResource.Compliance.ERROR, + ) + project.extra_data = { + "license_clarity_compliance_alert": "warning", + "scorecard_compliance_alert": "error", + } + 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 = ( + "3 compliance issues detected." + "\n[packages]\n > ERROR: 1" + "\n[license clarity]\n > WARNING" + "\n[scorecard compliance]\n > ERROR" + ) + 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/views.py b/scanpipe/views.py index d52eafcaa2..a0a9f29aeb 100644 --- a/scanpipe/views.py +++ b/scanpipe/views.py @@ -1286,6 +1286,8 @@ def get_context_data(self, **kwargs): project.get_license_clarity_compliance_alert() ) + context["scorecard_compliance_alert"] = project.get_scorecard_compliance_alert() + return context