Skip to content

fix: delete suggested reference_controls/threats for requirement nodes AND add annotation support for threats#4064

Open
monsieurswag wants to merge 15 commits into
mainfrom
CA-1661-Ref.-Controls-Threats-Update-Problems
Open

fix: delete suggested reference_controls/threats for requirement nodes AND add annotation support for threats#4064
monsieurswag wants to merge 15 commits into
mainfrom
CA-1661-Ref.-Controls-Threats-Update-Problems

Conversation

@monsieurswag
Copy link
Copy Markdown
Contributor

@monsieurswag monsieurswag commented Apr 29, 2026

This PR fixes 2 different bugs which are closely-enough related to be merged into a single PR.
(Details about these bugs can be found in jira CA-1661)

Bug 1:

The first bug is that the ["objects"]["threats"][idx]["annotation"] field path is ignored by the library importer even though it shouldn't (as the Threat model does contain an annotation field, and there's no reason to prevent library creators from modifying it).

Bug 2:

The second bug is that library updates can't delete ReferenceControl/Threat objects suggested to RequirementNode.
For example if a new version of a library remove a reference control URN from a ["objects"]["requirement_nodes"][idx]["reference_controls"] field path, the library update will NOT clear it out (and the user will have outdated/obsolete suggestions).
The ["objects"]["requirement_nodes"][idx]["threats"] has the exact same problem.

Summary by CodeRabbit

  • New Features

    • Threat imports now preserve optional annotation text from input.
  • Improvements

    • Requirement-node threat and reference-control links are fully synchronized to match incoming lists; URNs are resolved case-insensitively, deduplicated, and stale links are removed in bulk.
  • Refactor

    • Internal sorting helpers received stricter type annotations with no runtime behavior changes.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 29, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Tightened type hints for a helper; LibraryUpdater now normalizes, deduplicates, resolves (cache → single DB refetch) and replaces ManyToMany threat/reference_control links, detaches old ReferenceControl rows via bulk_update; ThreatImporter now persists an optional annotation when creating Threat records.

Changes

Helper type annotations

Layer / File(s) Summary
Signature refinements
backend/core/helpers.py
get_sorted_requirement_nodes now annotates requirement_nodes: list[RequirementNode] and requirements_assessed: Optional[list[RequirementAssessment]]; inner recursive helper get_sorted_requirement_nodes_rec start param annotated as list[RequirementNode]. No runtime behavior changed.

Library ReferenceControl detach + bulk-update cohort

Layer / File(s) Summary
Compute old/new URN sets & determine detach list
backend/core/models.py
update_reference_controls builds old_reference_controls_urn_set (from previous library) and a lowercased, deduplicated new_reference_controls_urn_set from incoming data, then computes detached_reference_controls_urn_set = old - new.
Clear FK and persist in bulk
backend/core/models.py
Fetches ReferenceControl rows matching detached URNs, sets their library = None, and saves changes via bulk_update.

RequirementNode M2M replace semantics

Layer / File(s) Summary
Normalize & dedupe input URNs
backend/core/models.py
Incoming threats and reference_controls URN lists are normalized to lowercase and deduplicated into sets.
Resolve via tracked cache then single DB fetch
backend/core/models.py
URNs are first resolved from objects_tracked cache; any misses are resolved with a single Model.objects.filter(urn__in=...) query per relation type.
Replace M2M relations atomically
backend/core/models.py
Resolved object sets are applied with .set(new_threats) and .set(new_reference_controls), replacing existing ManyToMany links instead of adding incrementally.

Threat importer: annotation persistence

Layer / File(s) Summary
Persist optional annotation
backend/library/utils.py
ThreatImporter.import_threat now passes an optional annotation into Threat.objects.create(...), persisting the provided annotation when present.

Sequence Diagram(s)

sequenceDiagram
    participant Updater as "RequirementNode Updater\n(routine)"
    participant Cache as "objects_tracked\n(in-memory cache)"
    participant DB as "Database\n(Threat/ReferenceControl models)"

    Updater->>Updater: receive requirement_node with URN lists
    Updater->>Updater: normalize & dedupe URNs (lowercase)
    Updater->>Cache: attempt resolve URNs
    Cache-->>Updater: return resolved objects + missing URNs
    alt missing URNs exist
        Updater->>DB: query objects where urn__in=missing_set
        DB-->>Updater: return resolved objects
    end
    Updater->>Updater: assemble final object sets
    Updater->>DB: call .set(new_threats) and .set(new_reference_controls)
    DB-->>Updater: persist exact M2M relationships
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 I hop through lines both new and old,

Lowercase urns in tidy fold,
Detached rows set free with care,
Annotations tucked in, saved to pair,
I nibble changes, light and bold.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 42.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes both main changes: fixing deletion of suggested reference controls/threats during library updates and adding annotation support for threats.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch CA-1661-Ref.-Controls-Threats-Update-Problems

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/core/models.py`:
- Around line 1270-1296: The update currently uses a fallback query
(Threat.objects.filter / ReferenceControl.objects.filter) that may return a
subset and then calls requirement_node_object.threats.set(new_threats) or
requirement_node_object.reference_controls.set(new_reference_controls), which
leads to destructive partial updates when some URNs are unresolved; change the
logic so after building new_threats_urns and new_reference_controls_urns you
verify that every URN maps to a resolved object (compare lengths or check for
None in new_threats/new_reference_controls), and if any are missing raise/return
a validation error (abort the library update) instead of falling back to a
partial queryset — only call requirement_node_object.threats.set(...) and
requirement_node_object.reference_controls.set(...) when all requested URNs are
resolved.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1b062228-703b-4443-831e-833a5a792f04

📥 Commits

Reviewing files that changed from the base of the PR and between a3eb7c6 and 9361c20.

📒 Files selected for processing (3)
  • backend/core/helpers.py
  • backend/core/models.py
  • backend/library/utils.py

Comment thread backend/core/models.py Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
backend/core/models.py (1)

1270-1299: ⚠️ Potential issue | 🟠 Major

Abort the update when a suggested URN does not resolve.

At Line 1275 and Line 1288, the fallback filter(urn__in=...) can return only a subset of the requested objects. The subsequent .set(...) then silently drops previously suggested threats/reference controls for the missing URNs, so a malformed library payload becomes a destructive partial update instead of an invalid update. Please verify that every incoming URN resolved before calling .set(...) and raise a validation error on any miss.

Suggested guard
                     new_threats_urns = {
                         urn.lower()  # URNs are case-insensitive
                         for urn in requirement_node.get("threats", [])
                     }
                     new_threats = [objects_tracked.get(urn) for urn in new_threats_urns]
                     if any(threat is None for threat in new_threats):
-                        # Edge case fix in case we ever have a threat missing from objects_tracked
-                        new_threats = Threat.objects.filter(urn__in=new_threats_urns)
+                        new_threats = list(Threat.objects.filter(urn__in=new_threats_urns))
+                        missing_threat_urns = new_threats_urns - {
+                            threat.urn.lower() for threat in new_threats
+                        }
+                        if missing_threat_urns:
+                            raise ValidationError(
+                                _("Unknown threat URNs in library update: %(urns)s"),
+                                params={"urns": ", ".join(missing_threat_urns)},
+                            )

                     requirement_node_object.threats.set(new_threats)

                     new_reference_controls_urns = {
                         urn.lower()  # URNs are case-insensitive
                         for urn in requirement_node.get("reference_controls", [])
                     }
                     new_reference_controls = [
                         objects_tracked.get(urn) for urn in new_reference_controls_urns
                     ]
                     if any(
                         reference_control is None
                         for reference_control in new_reference_controls
                     ):
-                        # Edge case fix in case we ever have a reference_control missing from objects_tracked
-                        new_reference_controls = ReferenceControl.objects.filter(
-                            urn__in=new_reference_controls_urns
-                        )
+                        new_reference_controls = list(
+                            ReferenceControl.objects.filter(
+                                urn__in=new_reference_controls_urns
+                            )
+                        )
+                        missing_reference_control_urns = (
+                            new_reference_controls_urns
+                            - {
+                                reference_control.urn.lower()
+                                for reference_control in new_reference_controls
+                            }
+                        )
+                        if missing_reference_control_urns:
+                            raise ValidationError(
+                                _(
+                                    "Unknown reference control URNs in library update: %(urns)s"
+                                ),
+                                params={
+                                    "urns": ", ".join(missing_reference_control_urns)
+                                },
+                            )

                     requirement_node_object.reference_controls.set(
                         new_reference_controls
                     )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/core/models.py` around lines 1270 - 1299, The current fallback that
replaces missing lookups with Threat.objects.filter(...) and
ReferenceControl.objects.filter(...) can silently drop unresolved URNs; update
the logic around requirement_node_object.threats.set and
requirement_node_object.reference_controls.set to first compare the set of
requested URNs (new_threats_urns / new_reference_controls_urns) against the URNs
actually resolved from objects_tracked and from the DB queries, and if any
requested URN is missing raise a validation error (e.g., Django ValidationError)
instead of calling .set(...); keep using objects_tracked as the primary source,
only use the DB to attempt resolution and then abort with an explicit error
listing unresolved URNs when resolution is incomplete.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@backend/core/models.py`:
- Around line 1270-1299: The current fallback that replaces missing lookups with
Threat.objects.filter(...) and ReferenceControl.objects.filter(...) can silently
drop unresolved URNs; update the logic around
requirement_node_object.threats.set and
requirement_node_object.reference_controls.set to first compare the set of
requested URNs (new_threats_urns / new_reference_controls_urns) against the URNs
actually resolved from objects_tracked and from the DB queries, and if any
requested URN is missing raise a validation error (e.g., Django ValidationError)
instead of calling .set(...); keep using objects_tracked as the primary source,
only use the DB to attempt resolution and then abort with an explicit error
listing unresolved URNs when resolution is incomplete.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 98f01bcd-6222-450c-bc1e-2a0d4cf484af

📥 Commits

Reviewing files that changed from the base of the PR and between 9361c20 and 9d6d02c.

📒 Files selected for processing (1)
  • backend/core/models.py

@nas-tabchiche nas-tabchiche changed the title fix: Delete suggested reference_controls/threats for requirement nodes AND add annotation support for threats fix: delete suggested reference_controls/threats for requirement nodes AND add annotation support for threats May 4, 2026
Copy link
Copy Markdown
Collaborator

@nas-tabchiche nas-tabchiche left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed both fixes work as expected.
One more thing before we can merge: some regression testing in app_tests/api/test_api_libraries.py would be insanely cheap to write, and valuable.

@tarkadia tarkadia self-requested a review May 5, 2026 15:19
Copy link
Copy Markdown
Contributor

@tarkadia tarkadia left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding/removing Threats & Ref. Controls from a requirement node works fine!

However, the fix for Annotation support for Threats doesn't work on my end.
Furthermore, if a Threat/Ref. Control is completely removed from a library, it remains visible in the Threats/Ref. Controls catalog. Will this bug be fixed in another PR? Because this is also an issue that has been reported in the Jira ticket.

@monsieurswag
Copy link
Copy Markdown
Contributor Author

Adding/removing Threats & Ref. Controls from a requirement node works fine!

However, the fix for Annotation support for Threats doesn't work on my end. Furthermore, if a Threat/Ref. Control is completely removed from a library, it remains visible in the Threats/Ref. Controls catalog. Will this bug be fixed in another PR? Because this is also an issue that has been reported in the Jira ticket.

The client complains about the reference control still being displayed as "suggested controls" in its audit.
This is what's being described by the BUG 2 section of the PR's description.


The annotation fix work as expected in the database on my side.
The following output shows it work for both library load AND library update operations.

>>> # AFTER LOADING THE LIBRARY
>>>
>>> for i in loaded_library.threats.all():
...     print(i.name, "::::::", i.annotation)
...
Extra Window Memory InjectionXXXXXXXXX :::::: AAA
Socket Filters :::::: BBB
Scheduled Task :::::: None
>>>
>>> # AFTER UPDATING THE LIBRARY
>>>
>>> for i in loaded_library.threats.all():
...     print(i.name, "::::::", i.annotation)
...
Extra Window Memory InjectionXXXXXXXXX :::::: AAA
Socket Filters :::::: BBB
Scheduled Task :::::: CCC

@monsieurswag
Copy link
Copy Markdown
Contributor Author

monsieurswag commented May 12, 2026

A new behavior was introduced by this PR.
When ReferenceControl are removed from the library BY the library update, the ReferenceControl library field is set to None (the reference control is "detached" from the library).

This can be seen in the following output:

>>> # AFTER LOADING THE LIBRARY
>>>
>>> for i in LoadedLibrary.objects.latest("created_at").reference_controls.all(): print(i.ref_id, i.library)
... 
ASF-REC-01 ASF-Baseline - UPDATED XXXXXXXXXXXXXX AAAAAAAAAAAAAAA Agile Security Framework - Baseline
ASF-REC-02 ASF-Baseline - UPDATED XXXXXXXXXXXXXX AAAAAAAAAAAAAAA Agile Security Framework - Baseline
ASF-REC-03 ASF-Baseline - UPDATED XXXXXXXXXXXXXX AAAAAAAAAAAAAAA Agile Security Framework - Baseline
ASF-REC-07 ASF-Baseline - UPDATED XXXXXXXXXXXXXX AAAAAAAAAAAAAAA Agile Security Framework - Baseline
ASF-REC-08 ASF-Baseline - UPDATED XXXXXXXXXXXXXX AAAAAAAAAAAAAAA Agile Security Framework - Baseline
ASF-REC-09 ASF-Baseline - UPDATED XXXXXXXXXXXXXX AAAAAAAAAAAAAAA Agile Security Framework - Baseline

>>> # AFTER UPDATING THE LIBRARY
>>>
>>> for i in R.objects.filter(ref_id__icontains="ASF-REC").all(): print(i.ref_id, i.library)
... 
ASF-REC-01 ASF-Baseline - UPDATED XXXXXXXXXXXXXX AAAAAAAAAAAAAAA Agile Security Framework - Baseline
ASF-REC-02 ASF-Baseline - UPDATED XXXXXXXXXXXXXX AAAAAAAAAAAAAAA Agile Security Framework - Baseline
ASF-REC-03 ASF-Baseline - UPDATED XXXXXXXXXXXXXX AAAAAAAAAAAAAAA Agile Security Framework - Baseline
ASF-REC-07 None
ASF-REC-08 None
ASF-REC-09 None

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/core/models.py`:
- Around line 831-833: old_reference_controls_urn_set is built from
self.old_library.reference_controls but isn't normalized, causing case-mismatch
false detachments; when creating old_reference_controls_urn_set (and the
analogous set around the 851-856 block) convert each URN to a canonical form
(e.g., .lower().strip() or the same normalization used for the incoming set) by
normalizing values returned by
self.old_library.reference_controls.all().values_list("urn", flat=True) before
putting them into the set so comparisons use the same normalized form.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 75bc382c-5f0d-4640-bef6-814049650560

📥 Commits

Reviewing files that changed from the base of the PR and between d80de04 and f5fd7b2.

📒 Files selected for processing (1)
  • backend/core/models.py

Comment thread backend/core/models.py
tarkadia
tarkadia previously approved these changes May 12, 2026
Copy link
Copy Markdown
Contributor

@tarkadia tarkadia left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding/removing Threats & Ref. Controls from a requirement node still works fine!
The "new behavior" for ReferenceControl works fine too. I'd like to get someon else's opinion whether it's a good approach or if it might create a side effect.

Following our discussion, we won't remove any Threats/Ref. Controls from DB if they are removed from the library file.
We also noticed that the convert_library_v2.py script doesn't set the annotation field for the main locale of the library, but does for extra locales. This will be fixed in another PR.

@Mohamed-Hacene
Copy link
Copy Markdown
Collaborator

In my opinion, removing reference controls from a library should have the same behavior than unloading a library with reference controls. We should prevent the update if some of the ref controls are used, and delete them if they are not, if the user don't want them anymore in the library, they surely don't want them in the db neither.

@monsieurswag
Copy link
Copy Markdown
Contributor Author

In my opinion, removing reference controls from a library should have the same behavior than unloading a library with reference controls. We should prevent the update if some of the ref controls are used, and delete them if they are not, if the user don't want them anymore in the library, they surely don't want them in the db neither.

Apparently Eric said we shouldn't delete them.

nas-tabchiche
nas-tabchiche previously approved these changes Jun 1, 2026
Copy link
Copy Markdown
Collaborator

@nas-tabchiche nas-tabchiche left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Comment thread backend/library/tests/test_custom_library_update.py Fixed
That's right.

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
@monsieurswag
Copy link
Copy Markdown
Contributor Author

Newly added tests (at commit hash: edadbd234e0770cb52eb6ddc7c00db74a0465522) LGTM.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants