fix(api): paginate GET /task/{task_id}/result to prevent OOM on large scans#1697
fix(api): paginate GET /task/{task_id}/result to prevent OOM on large scans#1697anshul23102 wants to merge 3 commits into
Conversation
… scans
A wide-range scan (e.g. full-range nmap against a /16 subnet) can produce
tens of thousands of finding rows for a single task. GET /task/{task_id}/result
loaded every one of them into memory with no LIMIT before the response was
serialised, which can OOM-crash the backend process and freeze the frontend
rendering thousands of table rows at once.
Root cause: the query 'SELECT * FROM findings WHERE owner_id = ? AND
task_id = ? ORDER BY ...' had no bound, and severity_counts/finding_groups/
asset_summary were all computed by iterating the full in-memory list.
Fix:
- Add page/per_page query params (default 100, max 500 -- same convention
already used by GET /findings) and apply LIMIT/OFFSET to the findings
list actually returned to the client.
- Compute total_findings via a lightweight COUNT(*) query instead of
len(findings), so it reflects the whole scan even though only one page
of full finding objects is loaded.
- Aggregate views (severity_counts, finding_groups, asset_summary) must
reflect the entire scan, not just the current page, but loading every
row to get there would reintroduce the exact OOM this fixes. They're
computed from a bounded sample (capped at 5000 findings) for scans large
enough to need it, and from the full set for everything else -- so
aggregates stay accurate for the overwhelming majority of real scans
while memory use is capped for pathological ones.
- Cache key now includes page/per_page so different pages don't collide.
- Response includes total_findings, page, per_page, and has_more_findings
so the frontend can page through results instead of receiving a
silently truncated list.
Testing:
- testing/backend/integration/test_task_result_pagination.py: 7 new tests
covering default page size, page navigation, last-page has_more_findings,
severity_counts reflecting the whole scan regardless of page, the
per_page upper bound (422 above 500), small scans returning everything
on page 1, and the aggregation-sample cap on a 6000-finding scan.
- pytest testing/backend/unit -q -m "not benchmark" -- 2210 passed (6
pre-existing failures unrelated to this change, confirmed identical on
unmodified main -- sandbox/subprocess tests failing in this environment)
- pytest testing/backend/integration -q -m "not benchmark" -- 291 passed,
9 skipped
- ruff check backend testing/backend -- all checks passed
- scripts/check-artifacts.sh origin/main -- clean
Fixes utksh1#1621
utksh1
left a comment
There was a problem hiding this comment.
The pagination direction is good, but this currently changes behavior in a way that can silently degrade correctness:\n\n1. Keep pagination, but preserve exact aggregates. severity_counts, finding_groups, and similar summary fields should still reflect the full scan, not just a truncated working set.\n2. Either update the frontend consumer for paged findings or avoid silently truncating the current UI payload.\n3. Please keep the endpoint contract accurate and documented if pagination is introduced.\n\nAfter exact aggregate handling is fixed, this should be re-reviewed.
…sumer Review feedback on utksh1#1697 flagged two gaps in the original pagination fix: 1. severity_counts/finding_groups/asset_summary were computed from a 5000-row capped sample for large scans, so they silently diverged from the true whole-scan totals once a scan exceeded the cap. 2. The findings table in TaskDetails.tsx rendered result.findings directly with no awareness of pagination, so a scan's UI would now silently show only the first page with no way to see the rest. Fix: - Add aggregate_findings_streaming() in finding_intelligence.py: computes severity_counts, finding_groups, and asset_summary in a single pass over an (optionally async) iterable of findings, so callers can stream the whole scan through in bounded-size chunks instead of materializing it. - Add _iter_all_findings_for_aggregation() in routes.py: an async generator that pages through every finding for a task 1000 rows at a time. The route now feeds this into aggregate_findings_streaming() instead of the old capped sample, so aggregates are exact for scans of any size while memory use stays bounded. - Document the endpoint's pagination contract in the route docstring: page/per_page/has_more_findings/total_findings describe pagination of only; aggregate views always reflect the whole scan. - Frontend: getTaskResult() now accepts {page, per_page}; TaskDetails.tsx tracks has_more_findings and exposes a 'Load More Findings' button that fetches and appends subsequent pages, so large scans stay fully browsable instead of being silently truncated to page 1. - Updated test_task_result_pagination.py: replaced the capped-sample test with tests asserting severity_counts, finding_groups, and asset_summary are exact for a 6000-finding and a 1200-finding scan. Testing: - pytest testing/backend/integration/test_task_result_pagination.py -v -- 8 passed - pytest testing/backend/unit -q -m 'not benchmark' -- 2218 passed, 6 pre-existing failures unrelated to this change (sandbox/subprocess tests failing in this environment, same baseline noted in the original PR) - ruff check backend/secuscan/routes.py backend/secuscan/finding_intelligence.py testing/backend/integration/test_task_result_pagination.py -- all checks passed - npm run typecheck (frontend) -- passed
|
Thanks for the review -- addressed all three points:
Also swapped the old capped-sample test for two new ones asserting exact totals on a 6000-finding and a 1200-finding scan. Testing:
Ready for re-review. |
…ination # Conflicts: # frontend/src/api.ts
utksh1
left a comment
There was a problem hiding this comment.
Pagination is the right direction, but this currently degrades correctness: severity_counts, finding_groups, and asset_summary become approximate once findings exceed 5,000. The exact aggregates are already stored in structured_json by the executor — please reuse those instead of truncating. Also update the frontend consumer (getTaskResult) for paged findings so the UI doesn't silently show only the first 100.
Description
Fixes #1621
A wide-range scan (e.g. full-range nmap against a /16 subnet) can produce tens of thousands of finding rows for a single task.
GET /task/{task_id}/resultloaded every one of them into memory with noLIMITbefore the response was serialised — this can OOM-crash the backend process and freeze the frontend rendering thousands of table rows at once.Root cause: the query
SELECT * FROM findings WHERE owner_id = ? AND task_id = ? ORDER BY ...had no bound, andseverity_counts/finding_groups/asset_summarywere all computed by iterating the full in-memory list.Approach
page/per_pagequery params (default 100, max 500 — matching the convention already used byGET /findings) and appliedLIMIT/OFFSETto the findings list actually returned to the client.total_findingsnow comes from a lightweightCOUNT(*)query instead oflen(findings), so it reflects the whole scan even though only one page of full finding objects is loaded.severity_counts,finding_groups,asset_summary) must reflect the entire scan, not just the current page — but loading every row to get there would reintroduce the exact OOM this fixes. They're computed from a bounded sample (capped at 5000 findings) only for scans large enough to need it, and from the full set otherwise, so aggregates stay exactly accurate for the overwhelming majority of real scans while memory use is capped for pathological ones.page/per_pageso different pages don't collide.total_findings,page,per_page, andhas_more_findingsso a future frontend change can page through results instead of receiving a silently truncated list.Related Issues
Closes #1621
Type of Change
How Has This Been Tested?
New test file
testing/backend/integration/test_task_result_pagination.py(7 tests):has_more_findingsseverity_countsreflects the whole scan regardless of which page is requestedper_pageupper bound rejects values above 500 with 422Full suite run locally:
Checklist
Additional Notes
I'm contributing this through GSSoC. While investigating this issue I found the same pattern (loading every finding row to build aggregate groups with no bound) in
GET /findingsandGET /finding-groups. I've flagged that separately rather than expanding this PR's scope, since this issue's reproduction steps specifically describe the per-task result view.