Skip to content

fix(importers): skip child rows buffered for a finding deleted mid-batch - #15428

Merged
Maffooch merged 2 commits into
bugfixfrom
claude/admiring-ramanujan-0d249c
Jul 30, 2026
Merged

fix(importers): skip child rows buffered for a finding deleted mid-batch#15428
Maffooch merged 2 commits into
bugfixfrom
claude/admiring-ramanujan-0d249c

Conversation

@Maffooch

Copy link
Copy Markdown
Contributor

Description

An async reimport fails with a 500 / failed task and this IntegrityError whenever a finding it has already processed is deleted before the batch it belongs to is flushed:

django.db.utils.IntegrityError: insert or update on table "dojo_vulnerability_id"
violates foreign key constraint "dojo_vulnerability_r_finding_id_c9d59919_fk_dojo_find"
DETAIL:  Key (finding_id)=(...) is not present in table "dojo_finding".

Reported twice within four seconds from a production instance on reimport-scan (Generic Findings Import), against two different tests of the same product — the shape of a delete racing a batch of reimports rather than a one-off.

The sequence:

  1. Vulnerability ids are not written as each finding is processed. store_vulnerability_ids() (import) and reconcile_vulnerability_ids() (reimport) append Vulnerability_Id objects to self.pending_vulnerability_ids, and flush_vulnerability_ids() bulk-inserts them at the batch boundary — up to a thousand findings later.
  2. A finding referenced by that buffer can be deleted inside the window. async_dupe_delete deletes excess duplicates when delete_duplicates is enabled, and a user deleting findings or a delete cascading from a test or engagement does the same.
  3. The bulk insert then carries a finding_id that is no longer in dojo_finding.

Because Django declares its foreign keys DEFERRABLE INITIALLY DEFERRED, that insert does not fail where it is issued — PostgreSQL raises it at COMMIT. That is why the reported traceback contains no application frames at all, only the commit path: the failure surfaces past every handler that still knew what the import was doing. The whole import dies, taking the rest of the batch — findings that were perfectly fine — with it.

BurpRawRequestResponse is buffered and flushed through exactly the same pattern (process_request_response_pairsflush_burp_request_response) and has the same exposure, so it is fixed alongside rather than left as the next incident.

The change

Both flushes now filter the buffer through a new BaseImporter.drop_rows_for_deleted_findings(), which keeps only the rows whose finding still exists and logs at warning level which finding ids were dropped and how many rows went with them.

Dropping is the correct outcome rather than a suppression: a finding that has been deleted has nothing to own these child rows. This is the same posture close_old_findings() already takes — _sync_close_old_finding_status_fields() returns only the candidates that still exist, precisely because a candidate can be deleted mid-reimport (#15408). This PR extends that guard to the other place in the same run that holds finding references across a batch.

Deliberately out of scope: why a finding may be deleted mid-run (that is the caller's workflow), and the pending_vuln_id_deletes half of the flush, which is a DELETE ... WHERE finding_id IN (...) and is unaffected by a missing row.

Performance impact

One indexed primary-key SELECT per flush that has something to insert, independent of finding count. Measured across the pinned baselines, every delta is exactly +1, and the no-change reimport — where nothing is buffered, so no lookup happens — is unchanged. That split is itself the evidence the cost is O(1) per flush and not O(findings):

before after
EXPECTED_ZAP_IMPORT_V2 / _V3 287 / 311 288 / 312
EXPECTED_ZAP_REIMPORT_WITH_NEW_V2 / _V3 148 / 177 149 / 178
EXPECTED_ZAP_REIMPORT_NO_CHANGE_V2 / _V3 74 / 86 74 / 86 (unchanged)

In test_importers_performance.py, expected_num_queries1 and expected_num_queries2 move +1 in all 11 affected cases; expected_num_queries3 (no-change reimport) and expected_num_queries4 are untouched. Async task counts are unchanged.

Impact on the Pro plugin

Checked as part of the acceptance criteria.

  • No Pro code change is required for the fix itself. ProImporter / ProReImporter derive from DefaultImporter / DefaultReImporter and override neither flush_vulnerability_ids, flush_burp_request_response, store_vulnerability_ids nor reconcile_vulnerability_ids, so they inherit the guard — which is what matters here, because the reported traceback runs through the Pro async importer into this file. No Pro module bulk-creates Vulnerability_Id on the import path, so there is no second copy of this defect to fix there.
  • The new method name does not collide with anything in the Pro importer hierarchy.
  • Pro does need a follow-up commit for its own pinned query counts. Pro keeps a separate test_importers_performance.py with its own expected_num_queries* values, which will shift by the same +1 on the buffering paths (and stay unchanged on the no-change reimport) once this merges. Those numbers have to be measured in the Pro stack, not carried over from the table above, and this sandbox has no Docker so they could not be produced here. Flagging rather than guessing.
  • One Pro observation worth noting separately, not a blocker for this PR: pro/finding/helpers.py creates a single Vulnerability_Id during EPSS enrichment for a finding held in memory, which is the same class of exposure in a different task. Different code path, different repo, not touched here.

Test results

New unittests/test_importers_deleted_finding_child_rows.py, 6 tests. The 4 guard tests were confirmed failing on bugfix before the change, each with the dangling-reference violation this PR is about:

FAILED (failures=1, errors=4)
django.db.utils.IntegrityError: update or delete on table "dojo_finding" violates foreign key
constraint "dojo_vulnerability_r_finding_id_c9d59919_fk_dojo_find" on table "dojo_vulnerability_id"
django.db.utils.IntegrityError: update or delete on table "dojo_finding" violates foreign key
constraint "dojo_burprawrequestr_finding_id_57e40ea9_fk_dojo_find" on table "dojo_burprawrequestresponse"

Each test calls connection.check_constraints() after the flush — the check PostgreSQL runs at COMMIT in production. The test transaction never commits, so without asking for it explicitly the production failure would not appear in a test at all.

Coverage: the import-path flush with a finding deleted after buffering; the reported reimport path through reconcile_vulnerability_ids; that a dropped row is not retried by the next batch's flush; the request/response buffer; and two control cases asserting a run where nothing was deleted still writes every row.

Run against PostgreSQL on Python 3.13 (the deferred-constraint behaviour is backend-specific):

  • unittests.test_importers_deleted_finding_child_rows6 passed
  • unittests.test_importers_performance, unittests.test_tag_inheritance_perf35 passed (26 count assertions failing on the un-updated baselines first, then green on the updated ones)
  • unittests.test_import_reimport, test_importers_importer, test_importers_closeold, test_importers_deduplication, test_reimport_prefetch, test_import_execution_mode, test_duplication_loops283 passed, 1 skipped, 0 failures
  • ruff check --config ruff.toml with the pinned 0.15.20 — clean on all four touched files

Environment note: this sandbox has no Docker, so suites were run through manage.py test against a locally provisioned PostgreSQL 16 cluster rather than run-unittest.sh, and scripts/update_performance_test_counts.py (which shells out to run-unittest.sh) could not be used. Every updated count is the value the assertion itself reported, re-verified on a second full run. CI is the authority.

Documentation

No documentation change: this restores the documented behaviour of import/reimport (the failure was never an expected outcome) and adds no setting, input, output, or API surface.

Checklist

  • Bugfixes should be submitted against the bugfix branch — based on and targeting bugfix.
  • Give a meaningful name to your PR, as it may end up being used in the release notes.
  • Your code is Ruff compliant (see ruff.toml).
  • Your code is python 3.13 compliant — tests were run on 3.13.
  • Add applicable tests to the unit tests.
  • Model changes must include the necessary migrations — N/A, no model changes.

Reported internally:

🤖 Generated with Claude Code

https://claude.ai/code/session_01WrYDZdT84SC27Mz53w1Yuj


Generated by Claude Code

Vulnerability ids and Burp request/response pairs are not written as each
finding is processed. They are buffered and bulk-inserted at the batch
boundary, so a finding stays referenced by the buffer for as long as the rest
of its batch (up to a thousand findings) takes to process. A finding row can
go away inside that window -- the excess-duplicate cleanup task deletes
findings, and so does a user deleting findings or a delete cascading from a
test or engagement -- leaving a buffered row pointing at a primary key that is
no longer in dojo_finding.

Because Django declares its foreign keys DEFERRABLE INITIALLY DEFERRED, that
insert does not fail where it is issued. PostgreSQL raises it at COMMIT, past
every handler that knew what the import was doing, so the reimport dies with
an IntegrityError naming a database constraint:

    insert or update on table "dojo_vulnerability_id" violates foreign key
    constraint "dojo_vulnerability_r_finding_id_c9d59919_fk_dojo_find"
    DETAIL: Key (finding_id)=(...) is not present in table "dojo_finding".

and takes the rest of the batch -- findings that were perfectly fine -- with
it.

A finding that is gone has nothing to own these rows, so both flushes now drop
just the rows whose finding no longer exists and log which ones, instead of
failing the whole import. The check is one indexed primary-key lookup per
flush that has something to insert, so a no-change reimport (nothing buffered)
is unaffected; close_old_findings already guards its candidates the same way.

Perf baselines move +1 on the paths that buffer child rows and are unchanged
on the no-change reimport.
@Maffooch
Maffooch requested a review from blakeaowens as a code owner July 30, 2026 06:46
@Maffooch Maffooch added this to the 3.2.0 milestone Jul 30, 2026 — with Claude
@Maffooch Maffooch added the bugfix label Jul 30, 2026 — with Claude
@dryrunsecurity

dryrunsecurity Bot commented Jul 30, 2026

Copy link
Copy Markdown

DryRun Security

This pull request contains a critical finding where the file 'dojo/importers/base_importer.py' was modified by an unauthorized author, 'claude'.

🔴 Configured Sensitive Codepath Modified by Non-Allowed Author in dojo/importers/base_importer.py (drs_ca7716a3)
Vulnerability Configured Sensitive Codepath Modified by Non-Allowed Author
Description File 'dojo/importers/base_importer.py' matches configured sensitive codepath pattern 'dojo/importers/*.py' and was modified by 'claude' (commit 1ae58d7) who is not in the allowed authors list.

We've notified @mtesauro.


Comment to provide feedback on these findings.

Report false positive: @dryrunsecurity fp [FINDING ID] [FEEDBACK]
Report low-impact: @dryrunsecurity nit [FINDING ID] [FEEDBACK]

Example: @dryrunsecurity fp drs_90eda195 This code is not user-facing

All finding details can be found in the DryRun Security Dashboard.

Conflicts were the pinned query-count baselines only, both sides raising the
same assertions: #15427's write-back guard (+3 on every path) against this
branch's buffered-child-row guard (+1 on the paths that buffer, unchanged on
the no-change reimport). The two guards are independent constant-cost lookups,
so each conflicting baseline takes bugfix's value plus this branch's delta, and
both explanatory comments are kept in test_tag_inheritance_perf.py. CI is the
authority on the resulting numbers.

Copy link
Copy Markdown
Contributor Author

CI failure on 983f09d8 is a pre-existing flake, not this PR

test-rest-framework (linux/arm64, false) failed with one test, and it is not in this diff:

FAIL: test_api_token_auth_is_rate_limited (unittests.test_apiv2_user.UserTest)
AssertionError: 4 != 3 : api-token-auth should honor the configured rate limit
Ran 4227 tests — FAILED (failures=1, skipped=574)

Evidence it is not caused by this branch:

  • Same commit, 3 of 4 unit-test jobs passed. linux/amd64 (false), linux/amd64 (true) and linux/arm64 (true) are all green on 983f09d8; only arm64 (false) failed. Identical code, different outcome, so the input is time, not the diff.
  • Different subsystem. This PR touches dojo/importers/base_importer.py and three test files. Nothing in it reaches rate limiting, dojo/decorators.py, or unittests/test_apiv2_user.py. That test arrived with Apply login rate limiter to the API token auth endpoint #15415, which is already on bugfix.
  • Passes locally, 3 runs of 3, against PostgreSQL on Python 3.13.

Why it flakes. dojo_ratelimit delegates to django-ratelimit, whose _get_window() pins the counter to a fixed window edge derived from the epoch — ts - (ts % period) + (crc32(key) % period) — and that window is part of the cache key. The test fires 8 requests under 3/m and asserts exactly 3 reach the view. If the window edge falls inside that 8-request loop, the counter resets mid-loop and a 4th request gets through: 4 != 3. The edge lands at one specific second per minute (jittered per client IP), so the test fails whenever the loop happens to straddle it — more likely on the slower arm64 runner. The assertion is exact-equality against a wall-clock-dependent boundary, which is the defect.

I am not fixing it here: it is an unrelated test in an unrelated subsystem, and quietly editing it inside a bugfix PR would hide a real flake behind an unrelated change. It deserves its own PR (waiting for a fresh window before the loop, or asserting <= 3). Flagging rather than absorbing it.

I have re-run the failed job. Everything from this PR passed on all four jobs, including the merged query-count baselines.

On the baseline merge conflict resolution

Independently re-verified locally on the merged tree (983f09d8) after bugfix brought in #15427: 47 tests, all passingtest_importers_performance, test_tag_inheritance_perf, test_importers_deleted_finding_child_rows (this PR) and test_importers_deleted_target (#15427). So the two guards compose as the merge commit assumed: each contested baseline is bugfix's value plus this branch's +1, and the no-change reimport stays flat because nothing is buffered on that path.


Generated by Claude Code

@Maffooch Maffooch modified the milestones: 3.2.0, 3.1.303 Jul 30, 2026
@Maffooch
Maffooch merged commit 2ffcf76 into bugfix Jul 30, 2026
278 of 279 checks passed
@Maffooch
Maffooch deleted the claude/admiring-ramanujan-0d249c branch July 30, 2026 20:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants