Skip to content

Commit 121b789

Browse files
Fix finding counts showing as 1 due to subquery ordering bug (#14242)
Hardened build_count_subquery to explicitly clear ordering and order by group_field before slicing. This prevents Django from adding implicit ORDER BY <pk> which causes GROUP BY to collapse counts to 1. Also updated prefetch_for_product_type to use the hardened helper instead of a local Subquery with the same vulnerability. Added unit tests to verify the fixes work correctly. Co-authored-by: Paul Osinski <42211303+paulOsinski@users.noreply.github.com>
1 parent 9e651dc commit 121b789

4 files changed

Lines changed: 50 additions & 9 deletions

File tree

dojo/product_type/views.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from django.contrib import messages
55
from django.contrib.admin.utils import NestedObjects
66
from django.db import DEFAULT_DB_ALIAS
7-
from django.db.models import Count, IntegerField, OuterRef, Subquery, Value
7+
from django.db.models import OuterRef, Value
88
from django.db.models.functions import Coalesce
99
from django.db.models.query import QuerySet
1010
from django.http import HttpResponseRedirect
@@ -82,13 +82,10 @@ def prefetch_for_product_type(prod_types):
8282
logger.debug("unable to prefetch because query was already executed")
8383
return prod_types
8484

85-
prod_subquery = Subquery(
86-
Product.objects.filter(prod_type_id=OuterRef("pk"))
87-
.values("prod_type_id")
88-
.annotate(c=Count("*"))
89-
.values("c")[:1],
90-
output_field=IntegerField(),
91-
)
85+
prod_subquery = build_count_subquery(
86+
Product.objects.filter(prod_type_id=OuterRef("pk")),
87+
group_field="prod_type_id",
88+
)
9289
base_findings = Finding.objects.filter(test__engagement__product__prod_type_id=OuterRef("pk"))
9390
count_subquery = partial(build_count_subquery, group_field="test__engagement__product__prod_type_id")
9491

dojo/query_utils.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@
44

55
def build_count_subquery(model_qs: QuerySet, group_field: str) -> Subquery:
66
"""Return a Subquery that yields one aggregated count per `group_field`."""
7+
# Important: slicing (`[:1]`) on an unordered queryset makes Django add an implicit `ORDER BY <pk>`.
8+
# With aggregation, Django then includes that pk in the GROUP BY, which collapses counts to 1.
9+
# Ordering by `group_field` avoids that and keeps the GROUP BY stable.
10+
model_qs = model_qs.order_by()
711
return Subquery(
8-
model_qs.values(group_field).annotate(c=Count("*")).values("c")[:1], # one row per group_field
12+
model_qs.values(group_field).annotate(c=Count("pk")).order_by(group_field).values("c")[:1], # one row per group_field
913
output_field=IntegerField(),
1014
)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from dojo.models import Product, Product_Type
2+
from dojo.product_type.views import prefetch_for_product_type
3+
from unittests.dojo_test_case import DojoTestCase, versioned_fixtures
4+
5+
6+
@versioned_fixtures
7+
class TestProductTypeCounts(DojoTestCase):
8+
fixtures = ["dojo_testdata.json"]
9+
10+
def test_prefetch_for_product_type_prod_count_matches_direct_count(self):
11+
product_type = Product_Type.objects.create(name="PT count test")
12+
Product.objects.create(name="PT product 1", description="test", prod_type=product_type)
13+
Product.objects.create(name="PT product 2", description="test", prod_type=product_type)
14+
15+
annotated = prefetch_for_product_type(Product_Type.objects.filter(id=product_type.id))
16+
annotated_count = annotated.values_list("prod_count", flat=True).get()
17+
18+
direct_count = Product.objects.filter(prod_type_id=product_type.id).count()
19+
self.assertEqual(annotated_count, direct_count)

unittests/test_query_utils.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from django.db.models import Count
2+
3+
from dojo.engagement.views import prefetch_for_view_tests
4+
from dojo.models import Finding, Test
5+
from unittests.dojo_test_case import DojoTestCase, versioned_fixtures
6+
7+
8+
@versioned_fixtures
9+
class TestQueryUtils(DojoTestCase):
10+
fixtures = ["dojo_testdata.json"]
11+
12+
def test_prefetch_for_view_tests_finding_counts_match_direct_count(self):
13+
test = Test.objects.annotate(finding_count=Count("finding")).filter(finding_count__gt=1).first()
14+
# If fixtures ever change, ensure we still have a representative test case.
15+
self.assertIsNotNone(test)
16+
17+
annotated = prefetch_for_view_tests(Test.objects.filter(id=test.id))
18+
annotated_count = annotated.values_list("count_findings_test_all", flat=True).get()
19+
20+
direct_count = Finding.objects.filter(test_id=test.id).count()
21+
self.assertEqual(annotated_count, direct_count)

0 commit comments

Comments
 (0)