Skip to content

Selectively Replace DeepDiff Due to Performance Issues - #3340

Open
liquidsec wants to merge 1 commit into
devfrom
bounded-http-body-comparison
Open

Selectively Replace DeepDiff Due to Performance Issues#3340
liquidsec wants to merge 1 commit into
devfrom
bounded-http-body-comparison

Conversation

@liquidsec

Copy link
Copy Markdown
Collaborator

Fixes #3339.

The hang

HttpCompare compared bodies with DeepDiff(..., ignore_order=True), whose similarity pairing is quadratic in the number of differing lines. The expensive band needs those lines to be numerous but individually near-identical — which is what a catch-all page echoing the request path produces, and also an ordinary homepage-vs-404 pair sharing template boilerplate.

Measured on a 583 KiB page:

Body Before After
8,007 lines / 1,600 echoed 71.9s 0.18s
16,007 lines / 3,200 echoed 259s 0.37s
32,007 lines / 6,400 echoed ~17 min (extrapolated) 0.75s

Two things beyond the issue report: the worse exposure was _baseline(), not compare_body — its diff had no exclude_paths to short-circuit pairing and ran directly on the event loop, accounting for 64s of the 72s above. And the XML path was exposed too (48s on a 248 KiB body), so this wasn't limited to line bodies.

The budgets suggested in the issue don't work. max_passes and both cutoff_* knobs gave no measurable speedup, and max_diffs=1000 returned an empty diff for two clearly-different bodies — a false "match", which would make wildcard detection and paramminer report the opposite of the truth.

The vacuous JSON comparison

Found while fixing the above; same root cause, so it's fixed here rather than separately.

The baseline filter's granularity was tied to the body's line breaks rather than its structure. A JSON API answers on one line, so a body containing any volatile field (CSRF token, timestamp, request id) produced exactly one differing line — index 0, the whole body. That single filter then suppressed everything.

The comparison wasn't degraded, it was vacuous — a constant True. Because it fails in the "no difference detected" direction it was silent, and it disabled the core signal of every module built on HttpCompare (paramminer, lightfuzz, bypass403, url_manipulation, webbrute) against JSON endpoints, while making is_http_wildcard_host log real sites as is an HTTP wildcard responder.

On a 222-byte JSON API body:

Subject Want Before After
same body, new csrf token match match match
value changed in results[3] differ match differ
added top-level error key differ match differ
completely unrelated JSON differ match differ

Approach

Both bugs come from comparing by line rather than by structure, so the unit of comparison changes:

  • Line bodies — matched by content, ignoring positions that varied between the two baseline samples. Preserves ignore_order's intent, linear.
  • JSON and XML — flattened to leaves keyed by path and compared per leaf, so volatile fields are filtered individually instead of taking the body with them. Works regardless of line breaks.
  • Baseline parsing moves into run_in_executor_cpu.
  • Debug log when a comparison exceeds 2s, with body size and elapsed time.
  • Structured parsing is bounded at 2 MB; larger bodies compare as lines, so a huge body can't pin a large leaf map to the baseline for its lifetime. XML previously had no such bound.

DeepDiff is still used for header comparison — small, flat, no blowup risk. compare_body still accepts raw response text and compares it verbatim, which lightfuzz's crypto submodule depends on after stripping its own reflected probe values.

Rather than assume the replacement matched, I characterized DeepDiff's actual boolean contract here — set membership, with differing items suppressed by whichever index its pairing heuristic reported, effectively arbitrary once order is declared irrelevant. The replacement agrees with it on 599/600 randomized realistic bodies, and accuracy is a wash on a labelled scenario suite (each got 1 missed detection and 1 false difference out of 14). So the quadratic work was buying a report compare_body discarded, not better answers.

Caveat

The JSON change is a detection increase in a place that was silently dead, so these modules will start producing findings on JSON endpoints where they previously produced none.

Body comparison ran through DeepDiff with ignore_order=True, whose
similarity pairing is quadratic in the number of differing lines. On an
ordinary large page it stalled the whole scan: 8,007 lines took 72s and
32,007 lines took minutes, all while holding the GIL. Reached from
is_http_wildcard_host, so any scan that probes a large catch-all host
could hit it regardless of enabled modules.

DeepDiff's own budgets don't help: max_passes and the cutoff_* knobs give
no speedup, and max_diffs silently returns an empty diff for bodies that
clearly differ, inverting the answer.

Compare bodies directly instead. Lines are matched by content, ignoring
positions that varied between the two baseline samples. This agrees with
DeepDiff on 599/600 randomized realistic bodies and is ~400x faster.
Baseline parsing also moves off the event loop, where it ran inline.

Parse JSON alongside XML and compare both leaf-by-leaf, keyed by path.
A JSON API answers on one line, so a body with any volatile field
produced a single differing line covering the whole body; that filter
then suppressed everything and compare_body returned "match" for every
subsequent response, including unrelated documents. The comparison was
not degraded but vacuous, silently disabling the core signal of every
module built on HttpCompare against JSON endpoints, and making
is_http_wildcard_host report real sites as catch-all responders.

compare_body still takes raw response text verbatim, which lightfuzz's
crypto submodule relies on after stripping its own reflected values.
@github-actions

Copy link
Copy Markdown
Contributor

📊 Performance Benchmark Report

Comparing dev (baseline) vs bounded-http-body-comparison (current)

📈 Detailed Results (All Benchmarks)

📋 Complete results for all benchmarks - includes both significant and insignificant changes

🧪 Test Name 📏 Base 📏 Current 📈 Change 🎯 Status
Bloom Filter Dns Mutation Tracking Performance 3.29ms 3.31ms +0.4%
Bloom Filter Large Scale Dns Brute Force 13.76ms 13.94ms +1.3%
Large Closest Match Lookup 264.31ms 259.16ms -2.0%
Realistic Closest Match Workload 141.78ms 137.93ms -2.7%
Event Memory Medium Scan 1402 B/event 1402 B/event +0.0%
Event Memory Large Scan 1527 B/event 1527 B/event +0.0%
Event Validation Full Scan Startup Small Batch 311.84ms 309.83ms -0.6%
Event Validation Full Scan Startup Large Batch 396.02ms 395.58ms -0.1%
Make Event Autodetection Small 15.53ms 15.48ms -0.4%
Make Event Autodetection Large 158.01ms 157.84ms -0.1%
Make Event Explicit Types 6.53ms 6.45ms -1.2%
Excavate Single Thread Small 3.228s 2.947s -8.7%
Excavate Single Thread Large 7.603s 7.343s -3.4%
Excavate Parallel Tasks Small 3.339s 3.083s -7.7%
Excavate Parallel Tasks Large 4.924s 4.653s -5.5%
Intercept Throughput Small 771.38ms 763.85ms -1.0%
Intercept Throughput Medium 755.95ms 751.56ms -0.6%
Dns Throughput Quiet 1.966s 1.972s +0.3%
Dns Throughput Loaded 1.317s 1.315s -0.2%
Dns Throughput Inherited 1.752s 1.794s +2.4%
Is Ip Performance 1.81ms 1.78ms -1.7%
Make Ip Type Performance 160.48µs 162.25µs +1.1%
Mixed Ip Operations 1.83ms 1.85ms +0.7%
Memory Use Web Crawl 1.0 MB 1.0 MB +1.0%
Memory Use Subdomain Enum 28.3 MB 28.3 MB +0.0%
Memory Use Deep Chain 5.0 MB 5.0 MB +0.0%
Memory Use Parallel Chains 10.9 MB 13.8 MB +26.8% 🔴🔴🔴 ⚠️
Scan Throughput 100 2.079s 2.038s -2.0%
Scan Throughput 1000 13.590s 13.552s -0.3%
Typical Queue Shuffle 4.26µs 4.25µs -0.2%
Priority Queue Shuffle 20.60µs 20.61µs +0.0%

🎯 Performance Summary

! 1 regression ⚠️
  30 unchanged ✅

🔍 Significant Changes (>10%)

  • Memory Use Parallel Chains: 26.8% 🐌 more memory

🐍 Python Version 3.11.15

@codecov

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 99.30796% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 90%. Comparing base (d4ad487) to head (873e567).

Files with missing lines Patch % Lines
bbot/core/helpers/diff.py 98% 2 Missing ⚠️
Additional details and impacted files
@@          Coverage Diff           @@
##             dev   #3340    +/-   ##
======================================
+ Coverage     90%     90%    +1%     
======================================
  Files        450     451     +1     
  Lines      46308   46577   +269     
======================================
+ Hits       41569   41837   +268     
- Misses      4739    4740     +1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@singlerider singlerider left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went through this one carefully since it changes behavior. Reproduced both bugs independently against dev, and both are real.

Vacuous JSON comparison. Simulated dev's baseline filter derivation on a 78-byte single-line JSON API body with a volatile csrf field. xmltodict fails, so the body becomes a 1-element list of lines, and the derived filter is root[0], the entire document. Result:

subject want dev PR
same body, new csrf token match match match
value changed in results[3] differ match differ
added top-level error key differ match differ
completely unrelated JSON differ match differ

PR's filter is {('csrf',)} instead of the whole body. Matches your table exactly.

Quadratic blowup. On a synthetic catch-all page (8,007 lines / 1,600 echoing the request path, 516 KiB): dev 20.9s, PR 0.63s, 33x. Lower than your 71.9s, probably a different body shape, but the shape of the problem is confirmed.

One thing I couldn't reproduce: you attribute the worse exposure to _baseline() rather than compare_body. On my repro dev spent 3.1s in baseline and 17.6s in compare, the reverse. Not disputing your measurement, just noting it's shape-dependent enough that the "64s of the 72s" split may not generalize.

42/42 of the new tests pass. Type-mismatch guards do the right thing both directions (structured-vs-line returns a difference, and a JSON baseline against a valid-XML error page compares leaf-wise and differs).

The memory cost isn't mentioned anywhere

_StructuredBody holds roughly 12x the body size, for the lifetime of the HttpCompare:

body leaves peak RSS vs body
597 KiB dense JSON 39,602 7.1 MB 12.1x
1191 KiB dense JSON 77,641 13.8 MB 11.9x
443 KiB realistic JSON n/a 6.4 MB ~14x

On dev these bodies cost essentially nothing, since a single-line body is a 1-element list. The benchmark bot flagged the only regression in the run here: Memory Use Parallel Chains 10.9 MB to 13.8 MB, +26.8%. That +2.9 MB absolute delta is the right order of magnitude for a single moderately sized JSON baseline at 12x.

MAX_STRUCTURED_BODY_SIZE bounds text length, not leaf count, and the two aren't proportional. Dense JSON packs far more leaves per byte than sparse. A body sitting just under 2 MB is the worst case at ~24 MB per baseline, per concurrent compare. Worth either lowering the bound, or bounding on leaf count after the parse and falling back to _LineBody past some ceiling.

Related: parse_body tries xmltodict before json, so well-formed XHTML becomes _StructuredBody and pays the same 12x. Only malformed HTML escapes to _LineBody. That ordering is inherited from dev, but it's newly expensive.

On the detection increase

Your caveat is the right call to flag. Since these modules were silently dead on JSON endpoints, there's no field data on their false-positive rate there. The 599/600 agreement measures agreement with a comparison that was returning a constant True for exactly the cases now changing. Not an argument against merging, but paramminer and lightfuzz against JSON APIs are worth watching on the first real scans after this lands.

Nice find on max_diffs returning an empty diff for differing bodies. That one would have been genuinely nasty to debug.

@liquidsec

Copy link
Copy Markdown
Collaborator Author

@singlerider Not too worried about the memory increase. Almost all of it is short-lived, freed when the event finishes. The one case worth more thought is is_http_wildcard_host, which caches the HttpCompare per host for the scan's lifetime.

@liquidsec liquidsec changed the title Compare HTTP bodies in linear time, structurally Selectively Replace DeepDeep Due to Performance Issues Jul 30, 2026
@singlerider singlerider mentioned this pull request Aug 17, 2026
4 tasks
@liquidsec liquidsec changed the title Selectively Replace DeepDeep Due to Performance Issues Selectively Replace DeepDiff Due to Performance Issues Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants