feat(vuln-id): normalize Vulnerability_Id into a VulnerabilityId entity + ordered references#15331
Draft
Maffooch wants to merge 15 commits into
Draft
feat(vuln-id): normalize Vulnerability_Id into a VulnerabilityId entity + ordered references#15331Maffooch wants to merge 15 commits into
Maffooch wants to merge 15 commits into
Conversation
Maffooch
added a commit
that referenced
this pull request
Jul 24, 2026
…s, add cutover-safety tests Review response (#15331) plus prep for the one-way legacy retirement. Rename (per review): VulnerabilityId -> Vulnerability; module dojo/vulnerability_id/ -> dojo/vulnerability/; table dojo_vulnerabilityid -> dojo_vulnerability (migration 0285 amended in place). String column stays vulnerability_id; reference FK stays `vulnerability`. Added vocabulary + porting-trap docstrings. Must-fixes: - MF4: UniqueConstraint(finding, order) on FindingVulnerabilityReference (model + migration 0285). - MF2: verify_vulnerability_ids downgrades order-0 != cve to a non-fatal warning when cve is not among the finding's ids (unreachable without breaking hash parity); stays a hard gate for genuine ordering drift. Bug fixes surfaced by the new tests: - cve-only hash-parity break: backfill step 4 fabricated a cve-only reference the live dual-write never creates, so entity reads diverged from legacy (dedup churn on deploy). Removed; the reference set now mirrors the legacy row set exactly. cve stays an orphan registry entity. - reimporter N+1: reconcile read the non-prefetched legacy relation under flag-on. Now reads through the seam (matches the prefetch). Perf re-baselined: -5 queries per reimport across 24 assertions. Reader ports toward the cutover (route through the flag seam, reversible): Finding.copy, reimporter reconcile. Tests (fixture-free, both flag states): legacy-independence (poison), dedup differential, real-scanner corpus (8 parsers), enumerable shapes (cve-only/null-cve/unicode/quotes/copy), MF2 warning, MF4 constraint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… models
Introduce the OSS vulnerability_id module (schema only, no behavior change):
- dojo/vulnerability_id/models.py: VulnerabilityId (global registry of id
strings, EPSS/KEV columns nullable, FTS/trigram/Upper indexes) and
FindingVulnerabilityReference (ordered Finding->VulnerabilityId link,
order==0 is primary). Plain models.Model with explicit created/updated:
BaseModel's manager would force order_by("id") and override the
Meta.ordering=["order"] the reference contract depends on, and its
full_clean-on-save must stay out of bulk paths.
- dojo/vulnerability_id/admin.py + __init__ admin import (mirrors
dojo/finding, dojo/location; autodiscover only scans top-level dojo.admin).
- dojo/vulnerability_id/queries.py: get_auth_filter-delegating seam accessors.
- dojo/models.py: facade re-export (string FKs => no circular import).
- settings.dist.py: DD_V3_FEATURE_VULNERABILITY_IDS=(bool, True) + assignment.
- prefetch/registrations.py + authorization/query_registrations.py: register
both models (unregistered => dropped from prefetch) with RBAC filters under
vulnerability_id.get_authorized_entities / .get_authorized_references.
makemigrations --check reports exactly these two models pending; manage.py
check clean; ruff clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- 0285_vulnerability_id_entity_tables: CreateModel x2 (empty tables => index DDL instant; >50M-row GIN-strip fallback documented in the header). - 0286_backfill_vulnerability_id_entities: atomic=False, Postgres-guarded, RunPython forward -> dojo.vulnerability_id.backfill.run_backfill (noop reverse). - backfill.run_backfill: idempotent, insert-only, set-based SQL shared with the WP5 migrate_vulnerability_ids command — (1) entities from legacy table (MAX type carry), (2) cve-only entities + Python type-stamp, (3) references windowed by finding_id (ROW_NUMBER cve-first then legacy-PK; order 0-based), (4) order-0 refs for cve-only findings. ON CONFLICT DO NOTHING everywhere. - test_migrations.TestVulnerabilityIdBackfill: seeds ordered / cve-drift / case-variant / cve-only / null-cve findings, asserts entity extraction, type, per-finding order (cve at 0), pair parity, and idempotent re-run. Uses a plain TestCase against run_backfill rather than MigratorTestCase: django_test_migrations replays the full squashed chain from a wiped schema in setUp and collides on legacy tables (dojo_cred_user) — the same reason the only other MigratorTestCase in this file is skipped. Migration application itself is covered by the create-db and dev-DB migrate runs. Verified: 0285/0286 apply cleanly on a fresh create-db build and on the dev DB; backfill test green; re-running run_backfill is a no-op. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every vulnerability-id writer now persists the legacy Vulnerability_Id rows byte-for-byte AND the new VulnerabilityId entity + FindingVulnerabilityReference rows, through a single seam. The flag gates reads only (WP4), so the two stores never drift. Legacy-write blocks are marked "TODO: Delete after Vulnerability_Id retirement". - dojo/vulnerability_id/manager.py: bulk_get_or_create_entities (race-safe bulk_create(ignore_conflicts)+refetch), persist_for_finding (single-finding core; does NOT touch finding.cve — caller keeps the cve sync), and VulnerabilityIdManager (importer accumulate/flush; record / record_reconcile / flush in one transaction). order == enumerate position => order 0 is the cve. - base_importer: store_vulnerability_ids -> manager.record; flush_vulnerability_ids -> manager.flush() then the unchanged Finding_CWE flush (CWE buffers are NOT owned by the manager and keep riding this flush boundary). - default_reimporter.reconcile_vulnerability_ids: keep the prefetched set-equality early-exit (legacy store authoritative) + cve sync; on change -> record_reconcile. - finding/helper.save_vulnerability_ids: dedupe/sanitize + cve sync unchanged; storage delegates to persist_for_finding. (migrate_cve stays legacy-only.) Tests: test_importers_importer updated to assert the manager's buffers (46 green); new fixture-free test_vulnerability_id asserts the entity-side dual-write, order, reconcile, orphan retention, and empty-clears for persist_for_finding, the manager, and save_vulnerability_ids; test_finding_helper.test_save_vulnerability_ids now asserts delegation to persist_for_finding + cve sync. Verified locally: test_importers_importer + test_vulnerability_id + TestSaveVulnerabilityIds green; manual import/reimport smoke on the dev DB shows both stores mirrored with correct order and orphan entities retained. The fixture-based suites (import_reimport / rest_framework / deduplication_logic / finding_helper API) run in OSS CI (dojo_testdata.json can't load in the Pro container: watson.searchentry). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reads switch to the VulnerabilityId entity/reference store when
V3_FEATURE_VULNERABILITY_IDS is on (default True); off keeps today's legacy-table
reads. The flag is consulted ONLY in dojo/vulnerability_id/queries.py and the two
Finding methods. Writes stay unconditionally dual (WP3), so both stores hold
identical data and the flip is reversible with no drift.
queries.py seam: use_entity_reads, finding_ids_with_vulnerability_ids (in/exact),
vulnerability_id_prefetch (with nested prefix), first_vulnerability_id_subquery
(order==0), finding_vulnerability_id_strings.
Re-pointed (identical output flag-off; entity read flag-on):
- Finding.vulnerability_ids property + get_vulnerability_ids() (cve prepend /
sorted-join kept byte-for-byte).
- dojo/filters.py both filter fns; risk_acceptance exact-match mixin.
- The 7 prefetch sites (deduplication, commands/dedupe, finding/ui + test/ui
views, finding/queries x2) -> vulnerability_id_prefetch().
- v2 serializer: VulnerabilityIdsField reads [{"vulnerability_id": s}] from the
flag-appropriate store (schema pinned via extend_schema_field); create/update
pop `parsed_vulnerability_ids` and still funnel writes to save_vulnerability_ids.
- Finding.copy() now dual-writes copied ids through persist_for_finding (was a
legacy-only .objects.create — would have been invisible flag-on).
Deliberately NOT re-pointed this PR (kept on the dual-written legacy table, which
stays correct in both flag states; entity migration deferred to the legacy-drop
phase): watson registration + the classic OSS search view. Rationale: watson is
disabled in the Pro runtime (untestable locally), the classic search view/template
are coupled to watson indexing Vulnerability_Id, and the classic UI is being
retired. This also removes the buildwatson-on-flip requirement. The Pro Vue global
search IS re-pointed to the entity in the Pro PR.
Tests (fixture-free, run locally + CI): test_vulnerability_id.TestReadSeamFlagParity
asserts the v2 wire shape, hash input (get_vulnerability_ids), the property, and the
filter seam are byte-identical flag-off vs flag-on; test_importers_importer reconcile
seeds via the dual-write seam so entity reads resolve. Verified green in BOTH flag
states locally; the fixture-based suites (rest_framework schema, import_reimport,
deduplication_logic, finding_helper API, watson trio) run in OSS CI.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- migrate_vulnerability_ids: wraps dojo.vulnerability_id.backfill.run_backfill (the same routine 0286 runs); --window-size; PostgreSQL-guarded; prints per-step row counts; idempotent/resumable so big tenants pre-run it before the deploy. - verify_vulnerability_ids: read-only reconciliation, nonzero exit on any drift so it can gate a ring flip. Per sampled finding (--sample, default 1000): every legacy (finding, id) pair has a reference; extra references allowed only when cve-only-derived (string == finding.cve); the order-0 reference == cve. Plus legacy-row / entity / reference / orphan-entity counts, both table names printed with roles. --json for machine output. Tests (fixture-free): migrate backfills legacy-only findings to entities/refs with order 0 == cve and is a no-op on re-run; verify passes on consistent data and raises SystemExit on a legacy row with no matching reference. Verified on the dev DB: migrate_vulnerability_ids runs clean and idempotent; verify_vulnerability_ids exits 0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… backfill -> flag parity) TestReadSeamFlagParity seeds via the dual-write seam, so both stores are trivially in sync. These new tests cover the actual UPGRADE scenario a deployment hits: legacy Vulnerability_Id rows written the old way (no entity refs), then backfilled by migrate_vulnerability_ids, then read flag-off (legacy) vs flag-on (entity). - test_backfilled_normal_data_reads_identically: for seam-shaped data (cve == first/ lowest-PK row) get_vulnerability_ids, the vulnerability_ids property, and the raw v2 wire shape are byte-identical across the flag. - test_backfilled_cve_not_first_row_preserves_hash_and_property: anomalous data where cve is not the first-created legacy row keeps hash + property byte-identical (both are order-insensitive by construction); only the RAW v2 wire ORDER differs (legacy PK-asc vs entity cve-first, same set) — asserted so the bounded divergence is deliberate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The unconditional dual-write of VulnerabilityId entities + FindingVulnerabilityReference rows adds a small constant per-import query delta (batched, not per-finding). Counts updated to match a pure-OSS CI run: dedup first_import +6 / second_import +4; reimport import1 +6 / reimport1 +12 / reimport2 +5, across TestDojoImporterPerformanceSmall and ...SmallLocations. Async-task counts unchanged (dual-write is synchronous). reimport3 (queries4) unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three test-only fixes surfaced by V3_FEATURE_VULNERABILITY_IDS defaulting True in OSS (reads hit the entity store): - test_bulk_risk_acceptance_api + test_pdf_report_rendering seeded vuln ids via the legacy Vulnerability_Id table directly, so entity reads returned empty flag-on. Seed via save_vulnerability_ids (unconditional dual-write) instead — production code is unchanged and correct. bulk_risk verified locally (3 tests green flag-on); the pdf report test loads dojo_testdata (can't run in the Pro container) and relies on OSS CI. - test_tag_inheritance_perf: re-baseline the 6 EXPECTED_ZAP_* assertNumQueries constants for the dual-write delta (+2 import / +12 reimport-no-change / +6 reimport-with-new), from a pure-OSS CI run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The rest-framework CI matrix runs a V3_FEATURE_LOCATIONS=True leg where base save() calls full_clean(). The new vuln-id test classes created Product/Test without the required description / scan_type / environment, so all 7 setUpClass hooks errored with ValidationError under that leg (they passed the False leg). Provide the required fields (Finding creation already skips validation via skip_validation=True, so it's unaffected). Reproduced locally with V3_FEATURE_LOCATIONS=True: 14 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s, add cutover-safety tests Review response (#15331) plus prep for the one-way legacy retirement. Rename (per review): VulnerabilityId -> Vulnerability; module dojo/vulnerability_id/ -> dojo/vulnerability/; table dojo_vulnerabilityid -> dojo_vulnerability (migration 0285 amended in place). String column stays vulnerability_id; reference FK stays `vulnerability`. Added vocabulary + porting-trap docstrings. Must-fixes: - MF4: UniqueConstraint(finding, order) on FindingVulnerabilityReference (model + migration 0285). - MF2: verify_vulnerability_ids downgrades order-0 != cve to a non-fatal warning when cve is not among the finding's ids (unreachable without breaking hash parity); stays a hard gate for genuine ordering drift. Bug fixes surfaced by the new tests: - cve-only hash-parity break: backfill step 4 fabricated a cve-only reference the live dual-write never creates, so entity reads diverged from legacy (dedup churn on deploy). Removed; the reference set now mirrors the legacy row set exactly. cve stays an orphan registry entity. - reimporter N+1: reconcile read the non-prefetched legacy relation under flag-on. Now reads through the seam (matches the prefetch). Perf re-baselined: -5 queries per reimport across 24 assertions. Reader ports toward the cutover (route through the flag seam, reversible): Finding.copy, reimporter reconcile. Tests (fixture-free, both flag states): legacy-independence (poison), dedup differential, real-scanner corpus (8 parsers), enumerable shapes (cve-only/null-cve/unicode/quotes/copy), MF2 warning, MF4 constraint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Read vulnerability ids exclusively from the Vulnerability entity + FindingVulnerabilityReference through-table. Remove the read flag and all flag-off branches, delete the legacy Vulnerability_Id model, and add migration 0287 dropping dojo_vulnerability_id (0286 backfills the entities first). - MF2: backfill no longer inserts cve-only references (preserves hash parity) - MF4: UniqueConstraint(finding, order) on the reference table - Reimporter reads finding_vulnerability_id_strings (fixes a latent N+1) - Repoint tests to the entity store; remove legacy-model coverage; delete the migrate_cve / verify_vulnerability_ids commands NOTE: test_tag_inheritance_perf EXPECTED_* query counts need recalibration under watson-enabled CI after the dual-write removal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dev added 0285_cicd_infrastructure, which collided with the vuln-id 0285. Renumber the three vuln-id migrations to chain linearly after it: 0285_vulnerability_id_entity_tables -> 0286 (dep: 0285_cicd_infrastructure) 0286_backfill_vulnerability_id_entities -> 0287 0287_delete_vulnerability_id -> 0288 makemigrations --check clean; single leaf (0288). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…for CI The entity-only cutover dropped the legacy Vulnerability_Id dual-write, which shifts watson-on query counts and exposed a hand-built-instance read in two reconcile tests (green locally with watson off; red in CI with watson on): - test_importers_performance: reimport steps -5 queries (no legacy delete+insert) - test_tag_inheritance_perf: reimport-no-change -12, reimport-with-new -6 - test_importers_importer reconcile tests: reload findings from the DB before reconcile so existing ids read from committed rows. Production loads reimport candidates via a prefetch query; a hand-built instance's empty reverse-relation cache made unchanged findings look changed. Counts set from OSS CI actuals (watson enabled; not measurable in the Pro env). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n-id lane
Second CI pass after the rebase onto newer dev:
- test_importers_performance: rebased dev shifted the baseline again (-1 on
import/dedup steps, -2 more on reimport1). Reset all 22 affected counts to the
current CI actuals; the warm-up primes FindingVulnerabilityReference.
- dojo/search/views.py: the classic /simple_search vuln-id lane queried the
deleted Vulnerability_Id via watson. An earlier stub (authorized_vulnerability_ids
= []) 500'd on watson.filter/.prefetch_related ('list' has no attribute ...),
breaking search_test and any UI test that visits /simple_search. Remove the lane
entirely (model is gone and unregistered from watson); Vue global search covers
vuln ids. The dev DD_WATSON_SEARCH_ENABLED toggle still 410s when watson is off.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The committed scanner corpus yields 49 distinct real ids; the >=50 bound was an arbitrary over-tight threshold. findings_checked>=20 already guards against a vacuous run, and 40 keeps a strong diversity guard while being robust to the corpus size. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
Shortcut: sc-13887
Normalize
Vulnerability_Idinto aVulnerabilityIdentity + ordered through-tableIntroduces a normalized vulnerability-id model without changing any observable behavior when the
feature flag is off.
VulnerabilityId— global registry of id strings (CVE-…,GHSA-…), stored EXACTLY assupplied (never case-normalized — the string is the natural key, display form, and hash input).
Carries nullable EPSS/KEV columns for future enrichment (NULL = "not enriched").
FindingVulnerabilityReference— orderedFinding → VulnerabilityIdlink;order == 0isthe primary id (==
Finding.cve), replacing the implicitcve == vulnerability_ids[0]coupling.Flag semantics
DD_V3_FEATURE_VULNERABILITY_IDSgates READS ONLY (dojo/vulnerability_id/queries.py+Finding.get_vulnerability_ids/.vulnerability_ids). Writes are unconditionally dual(
dojo/vulnerability_id/manager.py) — both stores always hold identical data, so the flag isreversible in both directions with no drift. Default
Trueinsettings.dist.py; cloud composepins
Falseand flips in rings.Migration speed & safety (0285 tables, 0286 backfill)
0286isatomic = False: each set-based statement — and eachfinding_idwindow (100k) in thereference build — commits independently. A crash mid-backfill resumes where it left off.
ON CONFLICT DO NOTHINGeverywhere → any re-run is ano-op on completed work.
dojo_vulnerability_idanddojo_findingare never read-locked,rewritten, or deleted, so concurrent readers/writers are not blocked during deploy.
orderis assigned cve-first then legacy-PK-ascending, soorder == 0is the cve by construction.ON CONFLICT); other backends log a warning and skip, pointing at themanagement command.
Upgrade note (draft)
manage.py migrate_vulnerability_idson the previous release; the0286migration is then an idempotent catch-up during the deploy.
manage.py verify_vulnerability_idscross-checks the two stores and exits nonzero on drift.Hash invariant (sacred)
Finding.get_vulnerability_ids()returns"".join(sorted(...)), so ordering is irrelevant to thehash; the 1:1 backfill/dual-write and
str(Vulnerability_Id) == .vulnerability_idmake the outputbyte-identical in both flag states.
vulnerability_id_typeis never part of the hash.Test evidence
TestReadSeamFlagParity— v2 wire shape, hash input,vulnerability_idsproperty, and filterseam are byte-identical flag on/off for seam-written findings.
TestUpgradeReadParity— the real upgrade path: legacy rows written the old way → backfilled →read flag-off vs flag-on. Normal (seam-shaped) data is fully identical; anomalous data (cve not
the first-created row) keeps hash + property byte-identical, with only the raw v2-wire order
differing (same set) — asserted so the bounded divergence stays deliberate.
TestVulnerabilityIdCommands— idempotent backfill + verify pass/fail.Not in scope (deferred to legacy drop)
Watson search index and the classic server-rendered search view remain on the dual-written legacy
table. The 107 parsers are untouched (they populate the in-memory
unsaved_vulnerability_ids,upstream of both stores).
🤖 Generated with Claude Code