Skip to content

backport(importers): make bulk_new_finding_writes functional on 3.2.x - #15770

Open
devGregA wants to merge 5 commits into
DefectDojo:bugfixfrom
devGregA:backport/bulk-write-seam
Open

backport(importers): make bulk_new_finding_writes functional on 3.2.x#15770
devGregA wants to merge 5 commits into
DefectDojo:bugfixfrom
devGregA:backport/bulk-write-seam

Conversation

@devGregA

Copy link
Copy Markdown
Contributor

Makes the Pro feature flag bulk_new_finding_writes actually functional on the 3.2.x line. It ships in 3.2.200 and is currently inert there: the Pro side defines persist_new_finding(), but the OSS seam that calls it only exists on dev, so nothing ever invokes the override. Enabling the flag on 3.2.x today changes nothing.

Please read the note on commit 4 before reviewing. This is not a uniformly low-risk backport.

What this brings across, all already on dev:

  1. refactor(locations): let locations be recorded before the finding is written #15597 - let locations be recorded before the finding is written. The accumulator keyed _locations_by_finding by the Finding object, and an unsaved finding is unhashable, so it becomes a list of (finding, locations) with an id()-keyed slot index. Conflicted on this line and was resolved by hand; only the one test class that refactor(locations): let locations be recorded before the finding is written #15597 itself added was taken, since the surrounding dev tests depend on APIs (cleaned_unsaved_locations) that are not on this line.

  2. refactor(importers): reconcile a finding's child rows before the finding is written #15620 - reconcile a finding's child rows before the finding is written. Three finding.pk guards so CWE and vulnerability-id reconciliation treat an unwritten finding as having no persisted rows rather than raising, and so a cve change does not trigger an early single-row insert.

  3. refactor(reimporter): a seam for deferring the new-finding write #15621 - the persist_new_finding() seam itself. One method call where there was one method call.

  4. derive_persisted_fields + persisted_title. THIS IS THE ONE TO SCRUTINISE. It extracts the column-derivation half of Finding.save() into a reusable method so a bulk writer can produce rows identical to what save() produces. The body is moved verbatim and the new-finding static/dynamic branch moves with it, because it reads only file_path and in-memory parser state; the existing-finding branch stays in save() since it queries relations and needs a primary key. Finding.save() is the hottest write path in the product and this is a patch line, so it deserves a closer read than the other three.

Ported by hand rather than cherry-picked: on dev this arrived inside a large model-split commit whose diff also carries unrelated index removals (#15659) and hashing changes (#15513, #15588), none of which belong here.

  1. The final-drain fix, which is fix(reimporter): drain the last partial batch however the loop ended #15762. Included because the flag cannot work without it: the buffered findings must be written before process_findings() computes self.untouched, which puts them in a set. It is the same commit, unmodified. If fix(reimporter): drain the last partial batch however the loop ended #15762 merges first this rebases away cleanly; it is also worth merging on its own regardless of this PR, because it is a live data-loss bug independent of any flag.

Verification

The point of the exercise, so it was checked rather than assumed. Before this PR, the Pro flag-on suite (unit_tests/connectors/test_bulk_new_finding_writer.py, 21 tests) passed while never executing the bulk path at all: a raise placed at the top of the flag resolver changed nothing, because the OSS seam that reaches it does not exist on this line. Those tests were passing vacuously.

With this PR the same 21 tests pass AND the same mutation now fails all 21 with the injected error, which is what proves the path is live and the tests are real gates.

ruff 0.16.0 (the version pinned in requirements-lint.txt) reports no findings across the branch.

devGregA and others added 5 commits August 23, 2026 10:46
…written (DefectDojo#15597)

LocationManager accumulated finding locations in a dict keyed by the Finding
itself. Django's Model.__hash__ raises on an instance with no primary key
("Model instances without primary key value are unhashable"), so recording a
location for a finding that has not been saved yet was a TypeError.

Nothing about persisting locations requires the finding to be hashable. Both
consumers in _persist_locations() simply iterate, and they run inside persist(),
by which point the findings are written. The dict was only ever there to coalesce
repeated records for one finding -- record_for_finding() records twice, once for
unsaved_locations and once for extras.

So the accumulator becomes a list of (finding, locations) pairs plus a slot index
keyed on object identity. That is the shape the tag accumulator threaded through
finding_post_processing() already uses. id() is safe here specifically because the
pair list holds a strong reference to every finding recorded, so no entry can be
collected and have its id reused while the accumulator is live.

Identity keying is also more correct than the dict was: two distinct unsaved
findings compare equal under Model.__eq__ (both have pk None), so an
equality-keyed accumulator would have merged their locations.

This unblocks an importer that buffers inserts and writes them in bulk at a batch
boundary -- it records a finding's locations while the finding is still unsaved.
The same accommodation get_original_findings, get_reimport_match_candidates_for_batch
and _flush_post_processing_batch already provide, for the same reason.

Tests: five in unittests/test_bulk_locations.py covering recording for an unsaved
finding, coalescing repeats, keeping two distinct unsaved findings apart, the
record-then-write-then-persist sequence end to end, and that both accumulators
clear together so a stale slot cannot outlive its list. Falsifiability checked by
reverting the source with the tests in place: all five fail with the TypeError
above, and pass with it.

Run natively (Django runner, PostgreSQL): test_bulk_locations, test_location_models,
test_merge_findings_locations, test_code_location_emission,
test_report_location_finding_scoping, test_importers_closeold,
test_importers_deduplication, test_reimport_batch_flush -- 131 OK. Plus
test_import_reimport and test_importers_performance -- 143 OK, with the pinned
query counts unchanged. ruff 0.16.1 (the requirements-lint.txt pin) clean.

Co-authored-by: devGregA <greg-agent-2@defectdojo.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 6c5e7a3)
…ing is written (DefectDojo#15620)

Vulnerability ids and CWEs are already buffered and flushed at the batch boundary
rather than written per finding. Reconciling them, though, read the finding's
existing rows back through a reverse relation, and Django refuses that on an
instance with no primary key:

    ValueError: 'Finding' instance needs to have a primary key value before
                this relationship can be used.

Both reads are unconditional -- reconcile_cwes() reads finding_cwe_set, and
reconcile_vulnerability_ids() reads through finding_vulnerability_id_strings()
before it compares anything -- so every finding hit them. An importer that buffers
the finding inserts themselves, to write them in bulk at that same batch boundary,
therefore could not reconcile at all.

For a finding with no row there is nothing to read: no persisted CWEs, no persisted
vulnerability ids, and nothing to delete. Treating the existing set as empty is
exact rather than a workaround -- every parsed value is new, which is what those
comparisons would conclude from an empty row set anyway.

Three guards, all no-ops on today's paths because the finding is always saved
before finding_post_processing() runs:

- reconcile_cwes(): skip the finding_cwe_set read when there is no pk, and do not
  append None to pending_cwe_deletes -- a finding with no row has no CWE rows to
  delete, and the None would land in the filter flush_vulnerability_ids() builds.
- reconcile_vulnerability_ids(): skip the finding_vulnerability_id_strings() read
  when there is no pk.
- finding_post_processing(): the trailing save on a changed cve exists to push that
  change onto an existing row. A buffered finding has no row to update, and saving
  there would insert it early, splitting one batched INSERT into per-finding ones.
  The value rides along when the buffer flushes.

This is the same accommodation get_original_findings,
get_reimport_match_candidates_for_batch and _flush_post_processing_batch already
provide so downstream editions can override behaviour without copying the full
process_findings() implementation.

Tests: four in unittests/test_importers_deleted_finding_child_rows.py, alongside the
existing buffered-child-row coverage -- reconciling vulnerability ids on an unsaved
finding, the record-then-write-then-flush sequence writing the reference correctly,
reconciling CWEs while queueing no delete, and a changed cve not writing a buffered
finding.

Falsifiability checked per guard rather than in aggregate. Reverting both reconcile
guards makes all four error with the ValueError above. Reverting ONLY the cve save
guard, with the other two in place, makes the buffered finding get written
(`103 is not None`) -- so each guard is independently necessary.

Run natively (Django runner, PostgreSQL): test_importers_deleted_finding_child_rows,
test_importers_importer, test_import_reimport, test_importers_closeold,
test_importers_deduplication, test_finding_cwe, test_vulnerability_id,
test_vulnerability_id_type, test_importers_performance, test_bulk_locations --
343 OK, with the pinned query counts unchanged. ruff 0.16.1 clean.

Co-authored-by: devGregA <greg-agent-2@defectdojo.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit e546fc9)
…ectDojo#15621)

process_finding_that_was_not_matched() writes each unmatched finding inline with
save_no_options(). A downstream edition that wants to buffer those inserts and
write them in bulk at the batch boundary has no way to do that today: the call is
in the middle of a ~35-line method, so overriding means copying the whole body,
which then drifts from upstream on every change to it.

Extracts the single call into persist_new_finding(), which defaults to exactly
what it replaces. Behaviour is unchanged -- one method call where there was one
method call.

This is the same accommodation get_original_findings and
get_reimport_match_candidates_for_batch already provide, and the docstring of the
former says so explicitly:

    This is intentionally a separate method (like
    get_reimport_match_candidates_for_batch) so downstream editions can override
    it without copying the full process_findings() implementation

The seam is placed deliberately: everything the caller does after it -- finding
groups, the new_items list, request/response pairs -- is already safe on a finding
that has not been written, and DefectDojo#15620 made the reconcile paths safe too. So
overriding this one method, plus _flush_post_processing_batch to write the buffer
before super(), is sufficient to defer the write. The docstring records that
contract so it is not re-derived.

Tests: unittests/test_reimporter_persist_seam.py drives a real BufferingReImporter
-- an actual downstream edition written out in full rather than a mock -- through
a genuine Acunetix reimport. It pins that the default still writes inline, that an
override can defer to the batch boundary and drains its buffer, that deferring
produces identical findings to the stock path, and that child rows still land
(which is what breaks if the buffer flushes after the block that needs primary
keys rather than before it).

That test is also what proved DefectDojo#15620 was a prerequisite rather than a nicety:
against a dev without those guards it errors in reconcile_cwes on
finding_cwe_set.all(); with them it passes.

Run natively (Django runner, PostgreSQL): test_reimporter_persist_seam,
test_importers_deleted_finding_child_rows, test_reimport_batch_flush,
test_import_reimport, test_importers_closeold, test_importers_deduplication,
test_importers_performance -- 216 OK, pinned query counts unchanged. ruff 0.16.1
clean.

Co-authored-by: devGregA <greg-agent-2@defectdojo.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit bc3be8d)
…sisted_fields

save() normalizes and derives a finding's own columns inline: title casing and
truncation, blank-component normalization, the date default, numerical severity,
CVSS vector parsing, and the same-tool hash. A batched writer using bulk_create
bypasses save() and its signals entirely, so it has no way to produce rows
identical to the ones save() produces short of reimplementing that transform.

Extracts it into derive_persisted_fields(), which save() now calls. Behaviour is
unchanged: the body is moved verbatim, and the new-finding static/dynamic branch
moves with it because it reads only file_path and the parser's in-memory
locations/endpoints. The existing-finding branch stays in save(), since it
queries self.locations/self.endpoints and needs a primary key.

Also adds Finding.persisted_title() as the single source of truth for the title
transform, replacing the hardcoded titlecase(title[:511]). It reads max_length
off the field rather than repeating the literal, and tolerates a None title.
This matters because titlecase() is not only a case change: it collapses
consecutive newlines and turns tabs into spaces, so a caller that truncated
without titlecasing diverged for any multi-line title.

Backport of the equivalent change on dev, ported by hand rather than
cherry-picked: on dev this arrived inside a large model-split commit whose diff
also carries unrelated index removals (DefectDojo#15659) and hashing changes (DefectDojo#15513,
DefectDojo#15588), none of which belong on a patch line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
process_findings() accumulates per-batch work and flushes it inside the per-finding
loop, gated on `len(batch_finding_ids) >= dedupe_batch_max_size or is_final`.

The matched branch ends in `continue`. That skip is what makes the tail fragile: the
flush sits after it in the loop body, so a report whose LAST finding took the matched
branch never reached the is_final flush, and everything appended since the previous
size-triggered flush was dropped. Silently, because nothing raises. Those findings got
no deduplication, no rules, no issue updater and no JIRA dispatch, and none of their
parser or inherited tags; pending location status updates, vulnerability ids and burp
request/response pairs were discarded with them.

A reimport whose final finding matches an existing one is the ordinary case, not an
edge case, so this fires on unchanged re-syncs rather than on unusual reports.

Extracts the flush into _flush_post_processing_batch() and calls it once after the
loop instead of relying on a flag inside it. The final call runs however the last
iteration ended, and is deliberately unconditional: matched findings can accumulate
location status updates without appending anything to dispatch, and every step is
already a no-op on empty state (close_old_findings calls persist() the same way). The
in-loop call keeps the size trigger only.

is_final and is_final_batch are now unused and removed, which also leaves the loop's
enumerate() index unused, so the loop iterates the batch directly.

Tests: unittests/test_reimport_final_drain.py reimports a report whose matches all
take the force_continue path, so the final iteration is guaranteed to be the skipping
one, and asserts the batch was still flushed. It fails on the code before this change
with exactly the dropped-work assertion and passes after. test_reimport_prefetch and
test_import_reimport were compared before and after and are unchanged (both carry
pre-existing failures on this branch that this change neither causes nor fixes).
ruff 0.16.0 clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dryrunsecurity

Copy link
Copy Markdown

DryRun Security

This pull request contains two critical findings where sensitive codepaths in dojo/finding/models.py and dojo/importers/default_reimporter.py were modified by an author not on the allowed list.

🔴 Configured Sensitive Codepath Modified by Non-Allowed Author in dojo/finding/models.py (drs_84cd4ab2)
Vulnerability Configured Sensitive Codepath Modified by Non-Allowed Author
Description File 'dojo/finding/models.py' matches configured sensitive codepath pattern 'dojo/finding/*.py' and was modified by '' (commit c6209d9) who is not in the allowed authors list.
🔴 Configured Sensitive Codepath Modified by Non-Allowed Author in dojo/importers/default_reimporter.py (drs_bec76e0a)
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 '' (commit 0097c65) 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.

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.

1 participant