diff --git a/scancodeio/static/main.css b/scancodeio/static/main.css index f85577d7d1..b3601252bf 100644 --- a/scancodeio/static/main.css +++ b/scancodeio/static/main.css @@ -196,6 +196,21 @@ pre.wrap { pre.is-small { padding: 0.75rem 1rem; } +.license-match-text { + max-height: 24rem; + overflow: auto; +} +.license-match-comparison pre { + margin-bottom: 0.75rem; +} +.license-match-block { + border-top: 1px solid var(--bulma-border-weak); + padding-top: 0.75rem; +} +.license-match-block:first-child { + border-top: 0; + padding-top: 0; +} .nexb-orange { color: rgb(var(--nexb-orange)); } diff --git a/scanpipe/templates/scanpipe/includes/license_match_comparison.html b/scanpipe/templates/scanpipe/includes/license_match_comparison.html new file mode 100644 index 0000000000..7d7806125f --- /dev/null +++ b/scanpipe/templates/scanpipe/includes/license_match_comparison.html @@ -0,0 +1,16 @@ +
+
+
+

Matched text

+
{{ match.matched_text|default:"" }}
+
+
+

Rule text

+
{{ match.rule_text|default:"" }}
+
+
+ {% if match.matched_text_diagnostics %} +

Diagnostic matched text

+
{{ match.matched_text_diagnostics }}
+ {% endif %} +
diff --git a/scanpipe/templates/scanpipe/tabset/tab_license_detections.html b/scanpipe/templates/scanpipe/tabset/tab_license_detections.html index e6060e1f4f..51ecbabeda 100644 --- a/scanpipe/templates/scanpipe/tabset/tab_license_detections.html +++ b/scanpipe/templates/scanpipe/tabset/tab_license_detections.html @@ -1,10 +1,58 @@
+ {% if file_match_groups %} +

Matches by file

+ {% for file_group in file_match_groups %} +
+
+
+ {% if file_group.file_region.path %} + {{ file_group.file_region.path }} + {% else %} + No origin resource path + {% endif %} +
+ {% if file_group.file_region.start_line or file_group.file_region.end_line %} +
+ Lines {{ file_group.file_region.start_line }}-{{ file_group.file_region.end_line }} +
+ {% endif %} +
+ {% if file_group.matches %} + {% for match in file_group.matches %} +
+
+ {{ match.license_expression }} + {% if match.matcher %}Matcher {{ match.matcher }}{% endif %} + {% if match.score %}Score {{ match.score }}{% endif %} + {% if match.match_coverage %}Coverage {{ match.match_coverage }}{% endif %} + {% if match.rule_identifier %} + + {% if match.rule_url %} + {{ match.rule_identifier }} + {% else %} + {{ match.rule_identifier }} + {% endif %} + + {% endif %} +
+ {% include "scanpipe/includes/license_match_comparison.html" with match=match only %} +
+ {% endfor %} + {% else %} +

No match text is available for this file region.

+ {% endif %} +
+ {% endfor %} + {% endif %} + + + @@ -25,6 +73,12 @@ + +
License expression Origin resource path Matched textDiagnostic matched textRule text Rule URL Score Matcher {{ match.matched_text }} + {{ match.matched_text_diagnostics }} + + {{ match.rule_text }} + {% if match.rule_url %} diff --git a/scanpipe/templates/scanpipe/tabset/tab_resource_detections.html b/scanpipe/templates/scanpipe/tabset/tab_resource_detections.html index a56351766a..329f2c532d 100644 --- a/scanpipe/templates/scanpipe/tabset/tab_resource_detections.html +++ b/scanpipe/templates/scanpipe/tabset/tab_resource_detections.html @@ -1,6 +1,6 @@
{% if tab_data.fields.license_detections.value %} - +
@@ -24,6 +24,45 @@ {% endfor %}
License detections
+ + {% for detection in tab_data.fields.license_detections.value %} +
+
+ +
+ {{ detection.license_expression }} + {% if detection.license_expression_spdx %} + {{ detection.license_expression_spdx }} + {% endif %} +
+
+ {% for match in detection.matches %} +
+
+ {{ match.license_expression }} + {% if match.start_line or match.end_line %}Lines {{ match.start_line }}-{{ match.end_line }}{% endif %} + {% if match.matcher %}Matcher {{ match.matcher }}{% endif %} + {% if match.score %}Score {{ match.score }}{% endif %} + {% if match.match_coverage %}Coverage {{ match.match_coverage }}{% endif %} + {% if match.rule_identifier %} + + {% if match.rule_url %} + {{ match.rule_identifier }} + {% else %} + {{ match.rule_identifier }} + {% endif %} + + {% endif %} +
+ {% include "scanpipe/includes/license_match_comparison.html" with match=match only %} +
+ {% empty %} +

No match text is available for this detection.

+ {% endfor %} +
+ {% endfor %} {% endif %} {% if tab_data.fields.license_clues.value %} diff --git a/scanpipe/tests/test_views.py b/scanpipe/tests/test_views.py index 09e26f376b..1c0ebd3710 100644 --- a/scanpipe/tests/test_views.py +++ b/scanpipe/tests/test_views.py @@ -42,6 +42,7 @@ from scanpipe.models import CodebaseRelation from scanpipe.models import CodebaseResource from scanpipe.models import DiscoveredDependency +from scanpipe.models import DiscoveredLicense from scanpipe.models import DiscoveredPackage from scanpipe.models import Project from scanpipe.pipes import make_relation @@ -1397,6 +1398,213 @@ def test_scanpipe_views_license_details_view(self): response = self.client.get(xss_url) self.assertEqual(response.status_code, 404) + def test_scanpipe_views_license_detection_details_view_match_texts(self): + matches = [ + { + "score": 100.0, + "start_line": 1, + "end_line": 1, + "matched_length": 4, + "match_coverage": 100.0, + "matcher": "1-hash", + "license_expression": "mit", + "rule_identifier": "mit_1.RULE", + "rule_text": "license: MIT", + "matched_text": "License: MIT", + "matched_text_diagnostics": "License MIT", + "from_file": "LICENSE", + } + ] + license_detection = DiscoveredLicense.objects.create( + project=self.project1, + license_expression="mit", + license_expression_spdx="MIT", + identifier="mit-123", + matches=matches, + file_regions=[{"path": "LICENSE", "start_line": 1, "end_line": 1}], + ) + + url = reverse( + "license_detail", + args=[self.project1.slug, license_detection.identifier], + ) + response = self.client.get(url) + + self.assertContains(response, "Diagnostic matched text") + self.assertContains(response, "License MIT") + self.assertContains(response, "Matched text") + self.assertContains(response, "License: MIT") + self.assertContains(response, "Rule text") + self.assertContains(response, "license: MIT") + + def test_scanpipe_views_license_detection_details_view_match_texts_null_from_file(self): + matches = [ + { + "score": 100.0, + "start_line": 1, + "end_line": 1, + "matched_length": 3, + "match_coverage": 100.0, + "matcher": "1-spdx-id", + "license_expression": "isc", + "rule_identifier": "isc_9931cb7ad33c2eb18f322c94660b670a84186baa.RULE", + "matched_text": "ISC", + "from_file": None, + } + ] + license_detection = DiscoveredLicense.objects.create( + project=self.project1, + license_expression="isc", + license_expression_spdx="ISC", + identifier="isc-null-from-file", + matches=matches, + detection_count=2, + file_regions=[ + {"path": "a/package.json", "start_line": 1, "end_line": 1}, + {"path": "b/package-lock.json", "start_line": 1, "end_line": 1}, + ], + ) + + url = reverse( + "license_detail", + args=[self.project1.slug, license_detection.identifier], + ) + response = self.client.get(url) + + self.assertContains(response, "Matches by file") + self.assertContains(response, "a/package.json") + self.assertContains(response, "b/package-lock.json") + self.assertContains(response, "ISC") + self.assertNotContains(response, "No match text is available for this file region.") + self.assertNotContains(response, "No origin resource path") + + def test_scanpipe_views_license_detection_details_view_match_texts_mixed_from_file(self): + matches = [ + { + "score": 100.0, + "start_line": 1, + "end_line": 1, + "matched_length": 4, + "match_coverage": 100.0, + "matcher": "1-hash", + "license_expression": "mit", + "rule_identifier": "mit_1.RULE", + "matched_text": "License: MIT", + "from_file": "LICENSE", + }, + { + "score": 100.0, + "start_line": 5, + "end_line": 5, + "matched_length": 3, + "match_coverage": 100.0, + "matcher": "1-spdx-id", + "license_expression": "apache-2.0", + "rule_identifier": "apache2_99.RULE", + "matched_text": "Apache-2.0", + "from_file": None, + }, + ] + license_detection = DiscoveredLicense.objects.create( + project=self.project1, + license_expression="mit AND apache-2.0", + license_expression_spdx="MIT AND Apache-2.0", + identifier="mixed-from-file", + matches=matches, + detection_count=3, + file_regions=[ + {"path": "LICENSE", "start_line": 1, "end_line": 1}, + {"path": "setup.py", "start_line": 5, "end_line": 5}, + ], + ) + + url = reverse( + "license_detail", + args=[self.project1.slug, license_detection.identifier], + ) + response = self.client.get(url) + + self.assertContains(response, "Matches by file") + self.assertContains(response, "LICENSE") + self.assertContains(response, "setup.py") + self.assertContains(response, "License: MIT") + self.assertContains(response, "Apache-2.0") + self.assertNotContains(response, "No match text is available for this file region.") + + def test_scanpipe_views_license_detection_details_view_match_texts_no_file_regions(self): + matches = [ + { + "score": 100.0, + "start_line": 1, + "end_line": 1, + "matched_length": 3, + "match_coverage": 100.0, + "matcher": "1-spdx-id", + "license_expression": "isc", + "rule_identifier": "isc_99.RULE", + "matched_text": "ISC", + "from_file": None, + } + ] + license_detection = DiscoveredLicense.objects.create( + project=self.project1, + license_expression="isc", + license_expression_spdx="ISC", + identifier="isc-no-regions", + matches=matches, + file_regions=[], + ) + + url = reverse( + "license_detail", + args=[self.project1.slug, license_detection.identifier], + ) + response = self.client.get(url) + + self.assertContains(response, "Matches by file") + self.assertContains(response, "ISC") + self.assertContains(response, "No origin resource path") + self.assertNotContains(response, "No match text is available for this file region.") + + def test_scanpipe_views_resource_details_view_inlines_detection_match_texts(self): + license_detections = [ + { + "identifier": "mit-123", + "license_expression": "mit", + "license_expression_spdx": "MIT", + "matches": [ + { + "score": 100.0, + "start_line": 1, + "end_line": 1, + "matched_length": 4, + "match_coverage": 100.0, + "matcher": "1-hash", + "license_expression": "mit", + "rule_identifier": "mit_1.RULE", + "rule_text": "license: MIT", + "matched_text": "License: MIT", + "matched_text_diagnostics": "License MIT", + } + ], + } + ] + resource = make_resource_file( + self.project1, + "LICENSE", + detected_license_expression="mit", + license_detections=license_detections, + ) + + response = self.client.get(resource.get_absolute_url()) + + self.assertContains(response, "Diagnostic matched text") + self.assertContains(response, "License MIT") + self.assertContains(response, "Matched text") + self.assertContains(response, "License: MIT") + self.assertContains(response, "Rule text") + self.assertContains(response, "license: MIT") + @mock.patch("scanpipe.models.DiscoveredPackage.get_absolute_url") def test_scanpipe_views_project_dependency_tree(self, mock_get_url): mock_get_url.return_value = "mocked-url" diff --git a/scanpipe/views.py b/scanpipe/views.py index 7981f2a7d7..cf348b7294 100644 --- a/scanpipe/views.py +++ b/scanpipe/views.py @@ -2535,6 +2535,57 @@ class DiscoveredLicenseDetailsView( }, } + @staticmethod + def get_matches_by_file(matches): + matches_by_file = {} + for match in matches: + from_file = match.get("from_file") or "" + matches_by_file.setdefault(from_file, []).append(match) + + return matches_by_file + + def get_file_match_groups(self): + matches_by_file = self.get_matches_by_file(self.object.matches) + file_match_groups = [] + + null_from_file_matches = matches_by_file.pop("", []) + + for file_region in self.object.file_regions: + path = file_region.get("path") or "" + matches = matches_by_file.pop(path, []) + if not matches and null_from_file_matches: + matches = null_from_file_matches + + file_match_groups.append( + { + "file_region": file_region, + "matches": matches, + } + ) + + for path, matches in matches_by_file.items(): + file_match_groups.append( + { + "file_region": {"path": path}, + "matches": matches, + } + ) + + if not file_match_groups and null_from_file_matches: + file_match_groups.append( + { + "file_region": {}, + "matches": null_from_file_matches, + } + ) + + return file_match_groups + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["file_match_groups"] = self.get_file_match_groups() + return context + @conditional_login_required def run_detail_view(request, uuid):