diff --git a/dojo/endpoint/models.py b/dojo/endpoint/models.py index 73379c6f538..628b28b9f2f 100644 --- a/dojo/endpoint/models.py +++ b/dojo/endpoint/models.py @@ -239,6 +239,11 @@ def __str__(self): def get_absolute_url(self): return reverse("view_endpoint", args=[str(self.id)]) + @property + def readable_tags(self): + """Mirror of ``Location.readable_tags``. An Endpoint belongs to one product, so all of them.""" + return list(self.tags.all()) + @classmethod @contextlib.contextmanager def allow_endpoint_init(cls): diff --git a/dojo/search/views.py b/dojo/search/views.py index 2f7f2235ed2..c9cf9e7cc9e 100644 --- a/dojo/search/views.py +++ b/dojo/search/views.py @@ -17,7 +17,8 @@ from dojo.finding.ui.filters import FindingFilter, FindingFilterWithoutObjectLookups from dojo.forms import FindingBulkUpdateForm, SimpleSearchForm from dojo.location.feature import locations_enabled -from dojo.location.queries import get_authorized_locations, prefetch_for_locations +from dojo.location.models import Location +from dojo.location.queries import get_authorized_locations, prefetch_for_locations, readable_tag_match from dojo.models import Engagement, Finding, Finding_Template, Product, Test from dojo.product.queries import get_authorized_app_analysis, get_authorized_languages, get_authorized_products from dojo.test.queries import get_authorized_tests @@ -237,7 +238,11 @@ def simple_search(request): tagged_tests = authorized_tests.filter(Q1 | Q2).exclude(Q3 | Q4).distinct()[:max_results].prefetch_related("tags") tagged_engagements = authorized_engagements.filter(Q1 | Q2).exclude(Q3 | Q4).distinct()[:max_results].prefetch_related("tags") tagged_products = authorized_products.filter(Q1 | Q2).exclude(Q3 | Q4).distinct()[:max_results].prefetch_related("tags") - tagged_endpoints = authorized_endpoints.filter(Q1 | Q2).exclude(Q3 | Q4).distinct()[:max_results].prefetch_related("tags") + if locations_enabled(): + L1, L2, L3, L4 = location_tag_queries(tag, tags, not_tag, not_tags) + tagged_endpoints = authorized_endpoints.filter(L1 | L2).exclude(L3 | L4).distinct()[:max_results].prefetch_related("tags") + else: + tagged_endpoints = authorized_endpoints.filter(Q1 | Q2).exclude(Q3 | Q4).distinct()[:max_results].prefetch_related("tags") else: tagged_findings = None tagged_finding_templates = None @@ -477,7 +482,42 @@ def vulnerability_id_fix(keyword): return keyword +def location_tag_queries(tag, tags, not_tag, not_tags): + """The four tag predicates of ``simple_search``, matched over readable tag sets only.""" + def match(**lookups): + return readable_tag_match("pk", **lookups) + return ( + match(tags__name__contains=tag) if tag else Q(), + match(tags__name__in=tags) if tags else Q(), + match(tags__name__contains=not_tag) if not_tag else Q(), + match(tags__name__in=not_tags) if not_tags else Q(), + ) + + +def apply_location_tag_filters(qs, operators): + """ + The Location branch of :func:`apply_tag_filters`. + + A Location row is shared by every product referencing it and so is its tag set, so a + filter joining the tag relation directly matches through other products' tags and turns + a substring lookup into an oracle over a set the page withholds. These run the same + predicate the templates render, so a filter never matches on a withheld value. + """ + if "tag" in operators: + qs = qs.filter(readable_tag_match("pk", tags__name__contains=",".join(operators["tag"]))) + if "tags" in operators: + qs = qs.filter(readable_tag_match("pk", tags__name__in=operators["tags"])) + if "not-tag" in operators: + qs = qs.exclude(readable_tag_match("pk", tags__name__contains=",".join(operators["not-tag"]))) + if "not-tags" in operators: + qs = qs.exclude(readable_tag_match("pk", tags__name__in=operators["not-tags"])) + return qs + + def apply_tag_filters(qs, operators, *, skip_relations=False): + if qs.model is Location: + return apply_location_tag_filters(qs, operators) + tag_filters = {"tag": ""} if qs.model == Finding: diff --git a/dojo/templates/dojo/request_endpoint_report.html b/dojo/templates/dojo/request_endpoint_report.html index 560feb134ce..535d6ecef45 100644 --- a/dojo/templates/dojo/request_endpoint_report.html +++ b/dojo/templates/dojo/request_endpoint_report.html @@ -63,7 +63,7 @@

{% if V3_FEATURE_LOCATIONS %} {{ e|truncatechars_html:70 }} - {% include "dojo/snippets/tags.html" with tags=e.tags.all %} + {% include "dojo/snippets/tags.html" with tags=e.readable_tags %} {{ e.active_findings }} {{ e.active_products }} diff --git a/dojo/templates/dojo/simple_search.html b/dojo/templates/dojo/simple_search.html index 864407c294b..463e9ba3075 100644 --- a/dojo/templates/dojo/simple_search.html +++ b/dojo/templates/dojo/simple_search.html @@ -271,7 +271,7 @@

{{ name }} {{ e }}{% if e.is_broken %} 🚩{% endif %} - {% include "dojo/snippets/tags.html" with tags=e.tags.all %} + {% include "dojo/snippets/tags.html" with tags=e.readable_tags %} {% if e.product %} @@ -421,7 +421,7 @@

{{ name }} {% trans "Endpoint" %} {{ endpoint }}{% if endpoint.is_broken %} 🚩{% endif %} - {% include "dojo/snippets/tags.html" with tags=endpoint.tags.all %} + {% include "dojo/snippets/tags.html" with tags=endpoint.readable_tags %} {% endfor %} diff --git a/unittests/test_location_tag_ui_scoping.py b/unittests/test_location_tag_ui_scoping.py new file mode 100644 index 00000000000..3a7c596853b --- /dev/null +++ b/unittests/test_location_tag_ui_scoping.py @@ -0,0 +1,122 @@ +""" +Location tag scoping on the two pages ``test_location_tag_scoping.py`` does not cover. + +That suite asserts the REST bodies and filters only, so the classic search page and the +Product Endpoint Report kept rendering and matching the raw shared tag relation. Both read +a Location the caller is authorized for, whose tag set belongs to every product on the row. +""" +from django.test import override_settings +from django.urls import reverse +from django.utils.timezone import now + +from dojo.authorization.roles_permissions import Roles +from dojo.models import ( + Dojo_User, + Engagement, + Finding, + Product, + Product_Member, + Product_Type, + Role, + Test, + Test_Type, + User, +) +from dojo.url.models import URL +from unittests.dojo_test_case import DojoTestCase, skip_unless_v3 + +SHARED_HOST = "uitagscope-shared.example.test" +OWN_HOST = "uitagscope-own.example.test" + +FOREIGN_TAG = "bsecretr7k2q9" +OWN_TAG = "aownlabel" + + +@skip_unless_v3 +@override_settings(WATSON_SEARCH_ENABLED=True) +class LocationTagUIScopingTest(DojoTestCase): + @classmethod + def setUpTestData(cls): + prod_type, _ = Product_Type.objects.get_or_create(name="UITagScope PT") + test_type, _ = Test_Type.objects.get_or_create(name="UITagScope Scan") + + def product(name): + return Product.objects.create(name=name, description=name, prod_type=prod_type) + + cls.mine = product("UITagScope Mine") + cls.theirs = product("UITagScope Theirs") + + cls.alice = User.objects.create_user( + username="uitagscope_alice", + password="not-a-real-secret", # noqa: S106 - test fixture user + ) + Product_Member.objects.create( + user=cls.alice, product=cls.mine, role=Role.objects.get(id=Roles.Reader), + ) + cls.mine.authorized_users.add(Dojo_User.objects.get(pk=cls.alice.pk)) + + engagement = Engagement.objects.create( + product=cls.mine, name="UITagScope eng", + target_start=now().date(), target_end=now().date(), + ) + test = Test.objects.create( + engagement=engagement, test_type=test_type, target_start=now(), target_end=now(), + ) + finding = Finding.objects.create( + test=test, title="UITagScope finding", severity="High", numerical_severity="S1", + active=True, verified=True, description="body", reporter=cls.alice, + ) + + def location(host, tag, *products): + loc = URL.get_or_create_from_values(protocol="https", host=host, path="x").location + for prod in products: + loc.associate_with_product(prod) + loc.associate_with_finding(finding, audit_time=now()) + loc.tags.set([tag]) + return loc + + # Alice's product references it, so the row is hers to see. The tag came from theirs. + cls.shared = location(SHARED_HOST, FOREIGN_TAG, cls.mine, cls.theirs) + cls.own = location(OWN_HOST, OWN_TAG, cls.mine) + + def _search(self, query): + self.client.force_login(self.alice) + response = self.client.get(reverse("simple_search"), {"query": query}) + self.assertEqual(response.status_code, 200) + return response.content.decode() + + def _report(self): + """The report options page, which lists the locations the report will cover.""" + self.client.force_login(self.alice) + response = self.client.get(reverse("product_endpoint_report", args=(self.mine.id,))) + self.assertEqual(response.status_code, 200) + return response.content.decode() + + def test_search_withholds_a_foreign_products_tag(self): + body = self._search("uitagscope") + self.assertIn(SHARED_HOST, body) + self.assertNotIn(FOREIGN_TAG, body) + + def test_search_still_renders_my_own_tag(self): + self.assertIn(OWN_TAG, self._search("uitagscope")) + + def test_search_tag_operator_does_not_match_a_foreign_tag(self): + for query in (f"tag:{FOREIGN_TAG}", f"tag:{FOREIGN_TAG[:6]}", f"tags:{FOREIGN_TAG}"): + self.assertNotIn(SHARED_HOST, self._search(query), query) + + def test_search_tag_operator_still_matches_my_own_tag(self): + for query in (f"tag:{OWN_TAG}", f"tag:{OWN_TAG[:6]}", f"tags:{OWN_TAG}"): + body = self._search(query) + self.assertIn(OWN_HOST, body, query) + self.assertIn(OWN_TAG, body, query) + + def test_search_negated_tag_operator_treats_a_foreign_tag_set_as_empty(self): + self.assertIn(SHARED_HOST, self._search(f"not-tag:{FOREIGN_TAG}")) + + def test_product_endpoint_report_withholds_a_foreign_products_tag(self): + body = self._report() + self.assertIn(SHARED_HOST, body) + self.assertNotIn(FOREIGN_TAG, body) + + def test_product_endpoint_report_still_renders_my_own_tag(self): + self.assertIn(OWN_TAG, self._report())