Repository navigation
perf(qualys): parse large Qualys XML reports in linear time - #16632
Conversation
The Qualys XML parser found each finding's QID details with a search from the document root, which walks every host before it reaches the glossary. Parsing cost findings x report size: a 56-host, 2,408-finding report took 40.0 s of CPU to parse, against 2.0 s for 7 hosts. The glossary is now indexed once per report and each finding reads its entry from that index. The first entry for an id still wins, as the search returned it, and the parsed findings are identical to before on reports of 7 to 56 hosts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adversarial review (full tier, backend parser only): changes requestedReviewed head What I ranOutput identity, old vs new parser. I loaded both parsers side by side in one process and compared every finding field in order: title, severity, description, mitigation, impact, references, date, mitigated, is_mitigated, active, cvssv3, cvssv3_score, verified, vuln_id_from_tool, vulnerability ids and endpoints. Each input ran under three settings: the defaults,
Complexity, counted with cProfile rather than timed. Total Python calls, old / new:
New is linear at about 280k calls per host; old grows quadratically. Best-of-3 CPU at 56 hosts on a loaded machine was 36.4 s old against 15.3 s new, which matches the PR's numbers. Memory. tracemalloc peak at 56 hosts: 28.0 MB old, 27.6 MB new. The index only holds references to elements already in the tree. A 300-host, 17.9 MB report (12,823 findings) parses on the new code at a 138 MB peak. XML security. Tests. I ran the test module's 14 tests against three parsers:
The 14 tests were called directly, without the DB test runner, because none of them touch the database. CI is green on the head commit. Ruff 0.16.10 (the FindingsR1: suggestion, scope this-pr. The new guard test misses the regression it is meant to catch if the per-finding search moves to an Suggested fix: patch R2: nit, scope this-pr. The description says the parsed output is identical to before. There is one input where it is not: a QID id containing an apostrophe. The old code put the id into an XPath predicate, so such an id made the import fail with R3: blocker-class bug, scope pre-existing (not this PR's to fix), filed as #16633. I confirmed the suspected missing-glossary bug on These trigger it: a deleted entry, an empty or missing glossary, a namespaced glossary, a VerdictChanges requested. Fix R1 and R2 on this branch. R3 is tracked in #16633. Not approved and not merged by this review. |
The guard only watched searches made on the whole tree, so a lookup made from the root element once per finding would have brought the quadratic parse back without failing it. Every find, findall, findtext and iterfind, on a tree or an element, resolves its path through ElementPath.iterfind, so that is what the test counts now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Resolutions:
|
Resolve the conflict with DefectDojo#16632 (linear-time Qualys parsing): upstream now builds a glossary dict and looks each QID up with glossary.get(), so keep that lookup and re-apply the missing-glossary guard on top of it — a finding whose QID has no VULN_DETAILS entry is skipped with a warning instead of crashing the whole import on vuln_item.findtext('SEVERITY'). Verified after the merge: manage.py test unittests.tools.test_qualys_parser -> Ran 15 tests OK (includes upstream's 2 new perf tests and our regression test), ruff 0.16.10 clean.
Description
The Qualys XML parser looked up each finding's QID details with
tree.find(".//GLOSSARY/VULN_DETAILS_LIST/VULN_DETAILS[@id='...']"). That search starts at the document root and walks every host before it reaches the glossary, which sits at the end of the report. So a parse cost (number of findings) x (size of the report), and grew quadratically with the number of hosts.The glossary is now indexed once per report (
vuln_details_by_id), and each finding reads its entry from that index. The first entry for an id still wins, as the search returned it.One behavior changes: the old search interpolated the QID into an XPath predicate, so a QID containing an apostrophe made the whole parse fail with
SyntaxError: invalid predicate. It now parses, because the id is a dictionary key rather than part of a path.Measured on the sample report with its hosts repeated (
unittests/scans/qualys/Qualys_Sample_Report.xml). Old and new parser ran interleaved in one process, best CPU time of 5:After the change, iteration steps equal the number of elements in the document. Total Python calls for the 56-host parse fell from 74.6M to 16.0M. The parsed findings are identical to before on every size above: title, severity, description, mitigation, impact, references, date, CVSS, status, vulnerability ids and endpoints were all compared.
End to end, through
POST /api/v2/reimport-scan/on the 56-host report (a reimport where half the hosts changed,group_by=finding_title,close_old_findings), with old and new parser run alternately on identical data: CPU per request fell from 65.3 / 65.6 / 68.6 s to 29.0 / 27.2 / 28.2 s across three rounds, with the same query counts.Test results
test_glossary_is_searched_once_per_report_not_once_per_finding: the number of QID-details lookups must not grow from a 2-host report to a 14-host report. It counts calls toxml.etree.ElementPath.iterfind, which everyfind,findall,findtextanditerfindgoes through, on a tree or an element, so a per-QID lookup fails it wherever the search starts. It fails on the old parser (45 vs 602) and on a variant that searches from the root element once per finding (45 vs 602), and passes now. It counts lookups rather than timing them, so it does not depend on machine load.test_repeated_glossary_entry_keeps_the_first: pins that a repeated glossary id resolves to its first entry, as before. It passes on both the old and the new parser.unittests/tools/test_qualys_parser.pypass. Ruff 0.16.10 is clean on both files.Documentation
No user-facing change; nothing to document.
Checklist
dev.devbranch.🤖 Generated with Claude Code