Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions scanpipe/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This needs a new entry in the REST API documentation, see _rest_api_license_clarity_compliance

"""
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."""
Expand Down
11 changes: 10 additions & 1 deletion scanpipe/management/commands/check-compliance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand All @@ -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):
Expand Down
7 changes: 7 additions & 0 deletions scanpipe/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
18 changes: 18 additions & 0 deletions scanpipe/pipelines/fetch_scores.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@

from scanpipe.models import DiscoveredPackageScore
from scanpipe.pipelines import Pipeline
from scanpipe.pipes.compliance_thresholds import get_project_scorecard_thresholds


class FetchScores(Pipeline):
Expand Down Expand Up @@ -58,9 +59,26 @@ def check_scorecode_service_availability(self):

def fetch_packages_scorecode_info(self):
"""Fetch ScoreCode information for each of the project's discovered packages."""
scorecard_policy = get_project_scorecard_thresholds(self.project)
worst_alert = None

for package in self.project.discoveredpackages.all():
if scorecard_data := ossf_scorecard.fetch_scorecard_info(package=package):
DiscoveredPackageScore.create_from_package_and_scorecard(
scorecard_data=scorecard_data,
package=package,
)

if scorecard_policy and scorecard_data.score is not None:
try:
score = float(scorecard_data.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:
self.project.update_extra_data({"scorecard_compliance_alert": worst_alert})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All this new logic should be moved to a pipe function. We want to keep the pipeline method a simple as possible. This would also make it easier to test (make sure to add one).

16 changes: 15 additions & 1 deletion scanpipe/templates/scanpipe/panels/project_compliance.html
Original file line number Diff line number Diff line change
@@ -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 %}
<div class="column is-half">
<nav id="compliance-panel" class="panel is-dark">
<p class="panel-heading">
Expand Down Expand Up @@ -33,6 +33,20 @@
</span>
</div>
{% endif %}
{% if scorecard_compliance_alert %}
<div class="panel-block">
<span class="pr-1">
Scorecard compliance
</span>
<span class="tag is-rounded ml-1
{% if scorecard_compliance_alert == 'error' %}is-danger
{% elif scorecard_compliance_alert == 'warning' %}is-warning
{% elif scorecard_compliance_alert == 'ok' %}is-success
{% else %}is-light{% endif %}">
{{ scorecard_compliance_alert|title }}
</span>
</div>
{% endif %}
</nav>
</div>
{% endif %}
18 changes: 18 additions & 0 deletions scanpipe/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
43 changes: 43 additions & 0 deletions scanpipe/tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/[email protected]",
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/[email protected]")
Expand Down
2 changes: 2 additions & 0 deletions scanpipe/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down