fix(importers): stop the write-back re-creating a deleted import target - #15427
Merged
Conversation
An object deleted while its scan was being processed was re-created by the importer's closing write-back. Model.save() on an instance whose primary key is already set issues an UPDATE and falls back to an INSERT when that UPDATE matches no rows. Both importers load their test and engagement at the start of a run that can take minutes, then save them again at the end, so a delete landing mid-run turned that write-back into an INSERT of the deleted row from the stale in-memory copy. Two outcomes, both wrong: - The parent went with it (an engagement delete cascades to its tests), so the INSERT carried a dangling foreign key. Django declares its foreign keys DEFERRABLE INITIALLY DEFERRED, so the violation was only raised at COMMIT, past any handler that still knew what the import was doing, and the API caller got an opaque 500 naming a PostgreSQL constraint instead of the reason the import failed. - The parent survived, so the INSERT succeeded and silently resurrected a row the user had deleted, without the findings and history cascaded away with it. Both write-back sites and update_test_progress now save through save_without_resurrecting(), which confirms the row is still there and otherwise raises with a message naming what was deleted. The import still fails - its target is gone - but it fails with the error that says so, and nothing is re-inserted. The row is checked before the write rather than with save(force_update=True): a forced update raises from inside the atomic block Model.save_base opens without a savepoint, marking the whole surrounding transaction for rollback, so the caller's failure handling would hit TransactionManagementError instead of being able to record why the import failed. Adds one indexed primary-key lookup per guarded save: +3 queries per import/reimport, independent of finding count. Expected counts in test_importers_performance.py updated accordingly (every delta is exactly +3; async task counts unchanged). Tests: unittests/test_importers_deleted_target.py, all confirmed failing first. Pre-fix the reimport completed successfully and left the deleted test row re-inserted.
|
This pull request contains critical security findings because the author 'claude' modified sensitive files in the 'dojo/importers/' directory without being on the allowed authors list.
🔴 Configured Sensitive Codepath Modified by Non-Allowed Author in
|
| 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 0625b7e) who is not in the allowed authors list. |
🔴 Configured Sensitive Codepath Modified by Non-Allowed Author in dojo/importers/default_importer.py (drs_c9e277b5)
| Vulnerability | Configured Sensitive Codepath Modified by Non-Allowed Author |
|---|---|
| Description | File 'dojo/importers/default_importer.py' matches configured sensitive codepath pattern 'dojo/importers/*.py' and was modified by 'claude' (commit 0625b7e) who is not in the allowed authors list. |
🔴 Configured Sensitive Codepath Modified by Non-Allowed Author in dojo/importers/default_reimporter.py (drs_c34043b5)
| Vulnerability | Configured Sensitive Codepath Modified by Non-Allowed Author |
|---|---|
| Description | File 'dojo/importers/default_reimporter.py' matches configured sensitive codepath pattern 'dojo/importers/*.py' and was modified by 'claude' (commit 0625b7e) 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.
… guard CI's unit-test suites caught six query-count assertions in test_tag_inheritance_perf.py that the importer write-back guard shifts, on top of the ones already updated in test_importers_performance.py. Every delta is +3, matching the other perf module exactly: one primary-key lookup per guarded save (test, engagement, and the closing update_test_progress). The delta is identical on the import and reimport paths and does not grow with finding count, which is the evidence the guard costs a constant three queries rather than one per finding. Reproduced locally before changing the numbers, and each new value taken from what the assertion actually reported rather than computed.
blakeaowens
approved these changes
Jul 30, 2026
devGregA
approved these changes
Jul 30, 2026
Maffooch
added a commit
that referenced
this pull request
Jul 30, 2026
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.
This was referenced Jul 30, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
An object deleted while its scan was being processed was re-created by the importer's closing write-back.
Model.save()on an instance whose primary key is already set issues anUPDATE, and Django falls back to anINSERTwhen thatUPDATEmatches no rows. Both importers load their test and engagement at the start of a run that can take minutes, then save them again at the end (default_importer.py/default_reimporter.py, "Save the test and engagement for changes to take affect"). A delete landing in between turned that write-back into anINSERTof the deleted row from the stale in-memory copy. Two outcomes, both wrong:INSERTcarried a danglingengagement_id. Django declares its foreign keysDEFERRABLE INITIALLY DEFERRED, so the violation was only raised atCOMMIT— past any handler that still knew what the import was doing. The API caller got an opaque 500 naming a PostgreSQL constraint (dojo_test_engagement_id_..._fk_dojo_engagement_id) instead of the reason the import failed.INSERTsucceeded and silently resurrected a row the user had deleted, without the findings and history that were cascaded away with it.Observed on a production instance on the synchronous
reimport-scanpath, repeatedly, on an instance whose CI creates and tears down short-lived per-pull-request engagements. The tell was the failure order: the constraint violation named the test row as already having an id, which is only possible if theUPDATE-to-INSERTfallback had run.Fix
Both write-back sites and
update_test_progress()now save through a newBaseImporter.save_without_resurrecting(), which confirms the row is still there and otherwise raises aValidationErrornaming what was deleted. The import still fails — its target is gone, and it cannot complete — but it fails with the error that says so, and nothing is re-inserted.The row is checked before the write rather than with
save(force_update=True). A forced update raises from inside the atomic blockModel.save_baseopens without a savepoint, which marks the whole surrounding transaction for rollback; the caller's failure handling would then hitTransactionManagementErrorinstead of being able to record why the import failed. Confirmed experimentally while developing this — the follow-up query needed to classify the error was itself refused. Costing one indexed primary-key lookup keeps the transaction usable.This is deliberately scoped to the resurrection. It does not change whether such an import succeeds, and it does not address why a target may be deleted mid-run — that is the caller's workflow.
Performance impact
One primary-key
SELECT 1 ... LIMIT 1per guarded save: +3 queries per import/reimport, independent of finding count. Expected counts intest_importers_performance.pyare updated accordingly — all 34 deltas are exactly +3, which is itself the evidence that the cost is O(1) and not O(findings). Async task counts are unchanged.Impact on the Pro plugin
Checked as part of the acceptance criteria; no Pro change is required.
test.save()/engagement.save()of its own on the importer write-back path, so there is no second copy of this defect to fix there.ProImporter/ProReImporterderive fromDefaultImporter/DefaultReImporterand do not overrideupdate_test_progress, so they inherit the guard.ConnectorsImporterand its subclasses inherit it too.process_scanin a try/except that records a failed status and re-raises astype(exception)(exception). A DjangoValidationErrorre-wraps cleanly, so the failure path is unaffected — and the message the caller now receives names the deleted object instead of an internal table.ValidationErrorfrom a vanished target is arguably a 4xx, which would be a change to Pro's import-error humanizer rather than to open source.Test results
New
unittests/test_importers_deleted_target.py, 6 tests. Every one was confirmed failing before the fix:The pre-fix reimport did not merely fail — it completed successfully and left the deleted row re-inserted, which a direct probe confirmed:
Coverage: the reimport write-back (test deleted, and engagement deleted so the test is cascaded away), the import write-back,
update_test_progress, the engagement half of the guard, and a control case asserting a run whose target is still present is unchanged and still persistspercent_complete.After the fix, run against PostgreSQL (not SQLite — the deferred-constraint behaviour above is backend-specific):
unittests/test_importers_deleted_target.py— 6 passedtest_importers_performance— 11 passed (was 11 passed on the unmodified base, then 34 count assertions failing, now green on the updated counts)test_importers_importer,test_importers_closeold,test_importers_deduplication,test_import_reimport,test_update_import_history,test_importers_performance,test_apiv2_scan_import_options,test_import_execution_mode,test_reimport_prefetch,test_endpoint_meta_import,test_generic_meta_import— 325 passed, 24 skipped, 0 failuresruff check --config ruff.toml(0.15.20, the pinned version) clean ondojo/importers/and both touched test filesDocumentation
No documentation change — this is a bug fix to internal importer behaviour, with no new or changed inputs, outputs, or settings.
Checklist
bugfix(bug fix, not a feature).Generated by Claude Code