Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
30 changes: 21 additions & 9 deletions dojo/location/api/endpoint_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,15 @@

from dojo.api_v2 import serializers
from dojo.api_v2.prefetch import PrefetchListMixin, PrefetchRetrieveMixin
from dojo.api_v2.serializers import TagListSerializerField
from dojo.api_v2.views import report_generate
from dojo.authorization.api_permissions import check_object_permission
from dojo.filters import CharFieldFilterANDExpression, CharFieldInFilter, OrderingFilter
from dojo.filters import OrderingFilter
from dojo.location.api.tag_filters import (
ReadableHasTagsFilter,
ReadableTagANDFilter,
ReadableTagFilter,
ReadableTagInFilter,
)
from dojo.location.models import LocationFindingReference, LocationProductReference
from dojo.location.queries import (
authorized_finding_references,
Expand Down Expand Up @@ -77,6 +82,10 @@ def has_object_permission(self, request, view, obj):
# Endpoint compatibility
##########

# Rows here are LocationProductReference, so tag lookups resolve against location_id.
_LOCATION = "location_id"


class V3EndpointCompatibleFilterSet(FilterSet):

"""Endpoint-compatible FilterSet."""
Expand All @@ -95,12 +104,12 @@ class V3EndpointCompatibleFilterSet(FilterSet):

location_id = NumberFilter(field_name="location__id", lookup_expr="exact")

tag = CharFilter(field_name="location__tags__name", lookup_expr="icontains", help_text="Tag name contains")
tags = CharFieldInFilter(field_name="location__tags__name", lookup_expr="in", help_text="Comma separated list of exact tags (uses OR for multiple values)")
tags__and = CharFieldFilterANDExpression(field_name="location__tags__name", help_text="Comma separated list of exact tags to match with an AND expression")
not_tag = CharFilter(field_name="location__tags__name", lookup_expr="icontains", help_text="Not Tag name contains", exclude=True)
not_tags = CharFieldInFilter(field_name="location__tags__name", lookup_expr="in", help_text="Comma separated list of exact tags not present on model", exclude=True)
has_tags = BooleanFilter(field_name="location__tags", lookup_expr="isnull", exclude=True, label="Has tags")
tag = ReadableTagFilter(lookup_expr="icontains", location_field=_LOCATION, help_text="Tag name contains")
tags = ReadableTagInFilter(location_field=_LOCATION, help_text="Comma separated list of exact tags (uses OR for multiple values)")
tags__and = ReadableTagANDFilter(location_field=_LOCATION, help_text="Comma separated list of exact tags to match with an AND expression")
not_tag = ReadableTagFilter(lookup_expr="icontains", exclude=True, location_field=_LOCATION, help_text="Not Tag name contains")
not_tags = ReadableTagInFilter(exclude=True, location_field=_LOCATION, help_text="Comma separated list of exact tags not present on model")
has_tags = ReadableHasTagsFilter(location_field=_LOCATION, label="Has tags")

o = OrderingFilter(
fields=(
Expand All @@ -126,14 +135,17 @@ class V3EndpointCompatibleSerializer(ModelSerializer):
path = CharField(source="location.url.path")
query = CharField(source="location.url.query")
fragment = CharField(source="location.url.fragment")
tags = TagListSerializerField(source="location.tags")
tags = SerializerMethodField()
location_id = IntegerField(source="location.id")
active_finding_count = IntegerField(read_only=True)

class Meta:
model = LocationProductReference
exclude = ("location",)

def get_tags(self, obj: LocationProductReference) -> list[str]:
return sorted(tag.name for tag in obj.location.readable_tags)


class V3EndpointCompatibleViewSet(PrefetchListMixin, PrefetchRetrieveMixin, viewsets.ReadOnlyModelViewSet):

Expand Down
3 changes: 2 additions & 1 deletion dojo/location/api/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from django_filters import NumberFilter

from dojo.api_helpers.filters import CommonFilters, StaticMethodFilters
from dojo.location.api.tag_filters import create_readable_tag_filters
from dojo.location.status import FindingLocationStatus, ProductLocationStatus


Expand All @@ -28,7 +29,7 @@ class LocationFilter(CommonFilters):
# ordering (the order of the fields is enforced)
CommonFilters.create_char_filters("location_type", "Location Type", locals())
CommonFilters.create_char_filters("location_value", "Location Value", locals())
CommonFilters.create_char_filters("tags__name", "Tags", locals())
create_readable_tag_filters("Tags", locals())
CommonFilters.create_integer_filters("products__product", "Product ID", locals())
CommonFilters.create_integer_filters("findings__finding", "Finding ID", locals())
CommonFilters.create_ordering_filters(
Expand Down
12 changes: 9 additions & 3 deletions dojo/location/api/serializers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from __future__ import annotations

from rest_framework.relations import PrimaryKeyRelatedField
from rest_framework.serializers import CharField
from rest_framework.serializers import CharField, SerializerMethodField

from dojo.api_helpers.serializers import BaseModelSerializer
from dojo.api_v2.serializers import TagListSerializerField
Expand Down Expand Up @@ -38,8 +38,14 @@ class LocationSerializer(BaseModelSerializer):

"""Serializer for the Location model with serializers for the related objects."""

tags = TagListSerializerField(required=False)
inherited_tags = TagListSerializerField(required=False)
tags = SerializerMethodField()
inherited_tags = SerializerMethodField()

def get_tags(self, obj: Location) -> list[str]:
return sorted(tag.name for tag in obj.readable_tags)

def get_inherited_tags(self, obj: Location) -> list[str]:
return sorted(tag.name for tag in obj.readable_inherited_tags)

class Meta:

Expand Down
99 changes: 99 additions & 0 deletions dojo/location/api/tag_filters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""
Tag filters that match only the tag sets the caller may read.

A Location row is shared by every product referencing it and so is its tag set, so a filter
that joins the tag relation directly matches through other products' tags. That turns any
substring lookup into a character-by-character oracle over a tag set the caller cannot read
in a response body. Every filter here runs its lookup inside
``dojo.location.queries.readable_tag_match`` instead, which is the same predicate the
serializers use, so a filter never matches on a value the body would withhold.
"""
from django_filters import BooleanFilter, CharFilter
from django_filters.constants import EMPTY_VALUES

from dojo.api_helpers.filters import CharFieldInFilter, StaticMethodFilters
from dojo.location.queries import readable_tag_match


class _ReadableTagFilterMixin:
location_field = "pk"

def __init__(self, *args, location_field="pk", **kwargs):
self.location_field = location_field
super().__init__(*args, **kwargs)

def _match(self, **lookups):
return readable_tag_match(self.location_field, **lookups)

def _apply(self, qs, match):
return qs.exclude(match) if self.exclude else qs.filter(match)


class ReadableTagFilter(_ReadableTagFilterMixin, CharFilter):
def filter(self, qs, value):
if value in EMPTY_VALUES:
return qs
return self._apply(qs, self._match(**{f"tags__name__{self.lookup_expr}": value}))


class ReadableTagInFilter(_ReadableTagFilterMixin, CharFieldInFilter):
def filter(self, qs, value):
names = _names(value)
if not names:
return qs
return self._apply(qs, self._match(tags__name__in=names))


class ReadableTagANDFilter(ReadableTagInFilter):
def filter(self, qs, value):
for name in _names(value):
qs = qs.filter(self._match(tags__name=name))
return qs


class ReadableHasTagsFilter(_ReadableTagFilterMixin, BooleanFilter):
def filter(self, qs, value):
if value in EMPTY_VALUES:
return qs
match = self._match(tags__isnull=False)
return qs.filter(match) if value else qs.exclude(match)


def _names(value):
if not value:
return []
if isinstance(value, str):
value = value.split(",")
return [name.strip() for name in value if name and name.strip()]


def create_readable_tag_filters(help_text_header, context, *, location_field="pk"):
"""Drop-in replacement for ``create_char_filters`` on a Location tag relation."""
def char(lookup, label, *, exclude=False):
return ReadableTagFilter(
lookup_expr=lookup,
exclude=exclude,
location_field=location_field,
help_text=f"{help_text_header}: {label}",
)

def in_list(label, *, exclude=False):
return ReadableTagInFilter(
exclude=exclude,
location_field=location_field,
help_text=f"{help_text_header}: {label}",
)

return StaticMethodFilters.set_class_variables(
context,
{
"tags__name_exact": char("iexact", "Exact Match"),
"tags__name_not_exact": char("iexact", "Not Exact Match", exclude=True),
"tags__name_contains": char("icontains", "Contains"),
"tags__name_not_contains": char("icontains", "Not Contains", exclude=True),
"tags__name_starts_with": char("istartswith", "Starts With"),
"tags__name_ends_with": char("iendswith", "Ends With"),
"tags__name_includes": in_list("Included in List"),
"tags__name_not_includes": in_list("Not Included in List", exclude=True),
},
)
17 changes: 17 additions & 0 deletions dojo/location/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,23 @@ class Location(BaseModel):
def __str__(self):
return self.location_value

@property
def readable_tags(self):
"""Tags on this row, or none of them when another product also labels the row."""
# ponytail: one EXISTS per rendered row; annotate the list querysets if it shows up.
from dojo.location.queries import location_tags_readable # noqa: PLC0415
if not location_tags_readable(self):
return []
return list(self.tags.all())

@property
def readable_inherited_tags(self):
"""The inherited subset of :attr:`readable_tags`."""
from dojo.location.queries import location_tags_readable # noqa: PLC0415
if not location_tags_readable(self):
return []
return list(self.inherited_tags.all())

def status_from_finding(self, finding: Finding) -> str:
"""Determine the status the reference should carry based on the status of the finding"""
# Set the default status to Active to be on the safe side
Expand Down
40 changes: 40 additions & 0 deletions dojo/location/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,46 @@ def locations_shared_outside(locations, products):
return locations.filter(Exists(foreign_products) | Exists(foreign_findings))


def readable_tag_locations(user=None):
"""
Locations whose tag set is entirely the caller's to read.

A Location row is deduplicated globally and its tag set is one field shared by every
product referencing it, with no record of which product contributed which tag. So the
set is only the caller's to read when they are authorized for every product on the row.
"""
products = get_authorized_products(Permissions.Product_View, user=user)
return Location.objects.exclude(
Exists(
LocationProductReference.objects.filter(
location=OuterRef("pk"),
).exclude(product__in=products),
)
| Exists(
LocationFindingReference.objects.filter(
location=OuterRef("pk"),
).exclude(finding__test__engagement__product__in=products),
),
)


def location_tags_readable(location, user=None):
"""Whether the caller may read the shared tag set on ``location``."""
return readable_tag_locations(user).filter(pk=location.pk).exists()


def readable_tag_match(location_field, user=None, **lookups):
"""
``Exists`` over readable tag sets, for filtering without joining the tag relation.

Use this rather than a joined ``filter()``: the host view runs ``distinct("url__host")``,
which a bare ``.distinct()`` added to deduplicate a join would clear.
"""
return Exists(
readable_tag_locations(user).filter(pk=OuterRef(location_field), **lookups),
)


def annotate_location_counts_and_status(locations, user=None):
# Annotate the queryset with counts of findings
# This aggregates the total and active findings by joining LocationFindingReference.
Expand Down
4 changes: 2 additions & 2 deletions dojo/templates/dojo/snippets/endpoints.html
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ <h4>Vulnerable Endpoints / Systems ({{ finding.active_endpoint_count }})
{% if V3_FEATURE_LOCATIONS %}
<td>
<a data-toggle="tooltip" data-placement="top" data-original-title="{{ endpoint.location }}" title="{{ endpoint.location }}" href="{% url 'view_endpoint' endpoint.object_id %}">{{ endpoint.location|url_shortener }}{% if endpoint.is_broken %} <span data-toggle="tooltip" title="Endpoint is broken. Check documentation to look for fix process" >&#128681;</span>{% endif %}</a>
{% include "dojo/snippets/tags.html" with tags=endpoint.location.tags.all %}
{% include "dojo/snippets/tags.html" with tags=endpoint.location.readable_tags %}
</td>
<td>{{ endpoint.status }}</td>
<td>{{ endpoint.created|date }}</td>
Expand Down Expand Up @@ -253,7 +253,7 @@ <h4>Mitigated Endpoints / Systems ({{ finding.mitigated_endpoint_count }})
{% if V3_FEATURE_LOCATIONS %}
<td>
<a data-toggle="tooltip" data-placement="top" data-original-title="{{ endpoint.location }}" title="{{ endpoint.location }}" href="{% url 'view_endpoint' endpoint.object_id %}">{{ endpoint.location|url_shortener }}{% if endpoint.is_broken %} <span data-toggle="tooltip" title="Endpoint is broken. Check documentation to look for fix process" >&#128681;</span>{% endif %}</a>
{% include "dojo/snippets/tags.html" with tags=endpoint.location.tags.all %}
{% include "dojo/snippets/tags.html" with tags=endpoint.location.readable_tags %}
</td>
<td>{{ endpoint.get_status_display }}</td>
<td>{{ endpoint.auditor }}</td>
Expand Down
2 changes: 1 addition & 1 deletion dojo/templates/dojo/url/list.html
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ <h3 class="has-filters">
title="Endpoint is broken. Check documentation to look for fix process">&#128681;</span>
{% endif %}
</a>
{% include "dojo/snippets/tags.html" with tags=location.tags.all %}
{% include "dojo/snippets/tags.html" with tags=location.readable_tags %}
</td>
{% endif %}
{% if not product_tab %}
Expand Down
4 changes: 2 additions & 2 deletions dojo/templates/dojo/url/view.html
Original file line number Diff line number Diff line change
Expand Up @@ -213,12 +213,12 @@ <h4>Host</h4>
</div>
{% endif %}
</div>
{% if not host_view and location.tags.exists %}
{% if not host_view and location.readable_tags %}
<div class="panel panel-default tags">
<div class="panel-heading">
<h4>Tags</h4>
</div>
<div class="tags panel-body">{% include "dojo/snippets/tags.html" with tags=location.tags.all %}</div>
<div class="tags panel-body">{% include "dojo/snippets/tags.html" with tags=location.readable_tags %}</div>
</div>
{% endif %}
{% if not host_view and metadata %}
Expand Down
2 changes: 1 addition & 1 deletion dojo/url/ui/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class Meta:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance is not None and hasattr(self.instance, "location"):
self.fields["tags"].initial = self.instance.location.tags.all()
self.fields["tags"].initial = self.instance.location.readable_tags

def clean_tags(self):
tag_validator(self.cleaned_data.get("tags"))
Expand Down
Loading
Loading