Skip to content

Commit 7d8eb5d

Browse files
blakeaowensclaude
andauthored
feat(search): add DD_WATSON_SEARCH_ENABLED toggle to gate watson indexing (#15236)
Master switch (default True, so OSS behaviour is unchanged). When False: watson models are not registered (no post-save index writes), the watson app + AsyncSearchContextMiddleware are stripped from settings, /simple_search returns 410, and installwatson is skipped (both the complete_initialization command and entrypoint-first-boot.sh). Lets deployments that have replaced watson (e.g. DefectDojo Pro's native Postgres search) stop paying the indexing cost and reclaim watson_searchentry. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f1484fd commit 7d8eb5d

6 files changed

Lines changed: 133 additions & 51 deletions

File tree

docker/entrypoint-first-boot.sh

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,13 @@ EOD
2727
python3 manage.py loaddata "${i%.*}"
2828
done
2929

30-
echo "Installing watson search index"
31-
python3 manage.py installwatson
30+
watson_enabled=$(echo "${DD_WATSON_SEARCH_ENABLED:-True}" | tr '[:upper:]' '[:lower:]')
31+
if [ "$watson_enabled" != "false" ] && [ "$watson_enabled" != "0" ] && [ "$watson_enabled" != "off" ] && [ "$watson_enabled" != "no" ]; then
32+
echo "Installing watson search index"
33+
python3 manage.py installwatson
34+
else
35+
echo "Skipping watson search index (DD_WATSON_SEARCH_ENABLED=${DD_WATSON_SEARCH_ENABLED})"
36+
fi
3237

3338
# surveys fixture needs to be modified as it contains an instance dependant polymorphic content id
3439
echo "Migration of textquestions for surveys"

dojo/apps.py

Lines changed: 57 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import logging
22

33
from django.apps import AppConfig
4+
from django.conf import settings
45
from django.core.checks import register as register_check
56
from django.db import models
67
from watson import search as watson
@@ -21,54 +22,11 @@ def ready(self):
2122
# commented out ^ as it prints in manage.py dumpdata, docker logs and many other places
2223
# logger doesn't work yet at this stage
2324

24-
# Watson doesn't have a way to let it index extra fields, so we have to explicitly list all the fields
25-
# to make it easier, we get the charfields/textfields from the model and then add our extra fields.
26-
# charfields/textfields are the fields that watson indexes by default (but we have to repeat here if we add extra fields)
27-
# and watson likes to have tuples instead of lists
28-
29-
watson.register(self.get_model("Product"), fields=get_model_fields_with_extra(self.get_model("Product"), ("id", "prod_type__name")), store=("prod_type__name", ))
30-
31-
watson.register(self.get_model("Test"), fields=get_model_fields_with_extra(self.get_model("Test"), ("id", "engagement__product__name")), store=("engagement__product__name", )) # test_type__name?
32-
33-
watson.register(self.get_model("Finding"), fields=get_model_fields_with_extra(self.get_model("Finding"), ("id", "url", "unique_id_from_tool", "test__engagement__product__name", "jira_issue__jira_key")),
34-
store=("status", "jira_issue__jira_key", "test__engagement__product__name", "severity", "severity_display", "latest_note"))
35-
36-
# some thoughts on Finding fields that are not indexed yet:
37-
# CWE can't be indexed as it is an integer
38-
39-
# would endpoints be good to index? or would it clutter search results?
40-
# endpoints = models.ManyToManyField(Endpoint, blank=True)
41-
# endpoint_status = models.ManyToManyField(Endpoint_Status, blank=True, related_name='finding_endpoint_status')
42-
43-
# index test name/title?
44-
# test = models.ForeignKey(Test, editable=False, on_delete=models.CASCADE)
45-
46-
# index reporter name?
47-
# reporter = models.ForeignKey(User, editable=False, default=1, related_name='reporter', on_delete=models.CASCADE)
48-
# index notes?
49-
# notes = models.ManyToManyField(Notes, blank=True, editable=False)
50-
51-
# index found_by?
52-
# found_by = models.ManyToManyField(Test_Type, editable=False)
53-
54-
# exclude these to avoid cluttering?
55-
# sast_source_object = models.CharField(null=True, blank=True, max_length=500, help_text="Source object (variable, function...) of the attack vector")
56-
# sast_sink_object = models.CharField(null=True, blank=True, max_length=500, help_text="Sink object (variable, function...) of the attack vector")
57-
# sast_source_line = models.IntegerField(null=True, blank=True,
58-
# verbose_name="Line number",
59-
# help_text="Source line number of the attack vector")
60-
# sast_source_file_path = models.CharField(null=True, blank=True, max_length=4000, help_text="Source filepath of the attack vector")
61-
62-
watson.register(self.get_model("Finding_Template"))
63-
# TODO: Delete this after the move to Locations
64-
watson.register(self.get_model("Endpoint"), store=("product__name", )) # add product name also?
65-
watson.register(self.get_model("Location"))
66-
watson.register(self.get_model("Engagement"), fields=get_model_fields_with_extra(self.get_model("Engagement"), ("id", "product__name")), store=("product__name", ))
67-
watson.register(self.get_model("App_Analysis"))
68-
watson.register(self.get_model("Vulnerability_Id"), store=("finding__test__engagement__product__name", ))
69-
70-
# YourModel = self.get_model("YourModel")
71-
# watson.register(YourModel)
25+
# Registering watson attaches post-save/pre-delete search indexers to 9
26+
# models (Finding is on the import hot path); skip it entirely when watson
27+
# is disabled so imports don't pay the write-amplification cost.
28+
if settings.WATSON_SEARCH_ENABLED:
29+
register_watson_models(self)
7230

7331
register_check(check_configuration_deduplication, "dojo")
7432

@@ -110,6 +68,57 @@ def ready(self):
11068
install_intermediate_flush_hook()
11169

11270

71+
def register_watson_models(app_config):
72+
# Watson doesn't have a way to let it index extra fields, so we have to explicitly list all the fields
73+
# to make it easier, we get the charfields/textfields from the model and then add our extra fields.
74+
# charfields/textfields are the fields that watson indexes by default (but we have to repeat here if we add extra fields)
75+
# and watson likes to have tuples instead of lists
76+
77+
watson.register(app_config.get_model("Product"), fields=get_model_fields_with_extra(app_config.get_model("Product"), ("id", "prod_type__name")), store=("prod_type__name", ))
78+
79+
watson.register(app_config.get_model("Test"), fields=get_model_fields_with_extra(app_config.get_model("Test"), ("id", "engagement__product__name")), store=("engagement__product__name", )) # test_type__name?
80+
81+
watson.register(app_config.get_model("Finding"), fields=get_model_fields_with_extra(app_config.get_model("Finding"), ("id", "url", "unique_id_from_tool", "test__engagement__product__name", "jira_issue__jira_key")),
82+
store=("status", "jira_issue__jira_key", "test__engagement__product__name", "severity", "severity_display", "latest_note"))
83+
84+
# some thoughts on Finding fields that are not indexed yet:
85+
# CWE can't be indexed as it is an integer
86+
87+
# would endpoints be good to index? or would it clutter search results?
88+
# endpoints = models.ManyToManyField(Endpoint, blank=True)
89+
# endpoint_status = models.ManyToManyField(Endpoint_Status, blank=True, related_name='finding_endpoint_status')
90+
91+
# index test name/title?
92+
# test = models.ForeignKey(Test, editable=False, on_delete=models.CASCADE)
93+
94+
# index reporter name?
95+
# reporter = models.ForeignKey(User, editable=False, default=1, related_name='reporter', on_delete=models.CASCADE)
96+
# index notes?
97+
# notes = models.ManyToManyField(Notes, blank=True, editable=False)
98+
99+
# index found_by?
100+
# found_by = models.ManyToManyField(Test_Type, editable=False)
101+
102+
# exclude these to avoid cluttering?
103+
# sast_source_object = models.CharField(null=True, blank=True, max_length=500, help_text="Source object (variable, function...) of the attack vector")
104+
# sast_sink_object = models.CharField(null=True, blank=True, max_length=500, help_text="Sink object (variable, function...) of the attack vector")
105+
# sast_source_line = models.IntegerField(null=True, blank=True,
106+
# verbose_name="Line number",
107+
# help_text="Source line number of the attack vector")
108+
# sast_source_file_path = models.CharField(null=True, blank=True, max_length=4000, help_text="Source filepath of the attack vector")
109+
110+
watson.register(app_config.get_model("Finding_Template"))
111+
# TODO: Delete this after the move to Locations
112+
watson.register(app_config.get_model("Endpoint"), store=("product__name", )) # add product name also?
113+
watson.register(app_config.get_model("Location"))
114+
watson.register(app_config.get_model("Engagement"), fields=get_model_fields_with_extra(app_config.get_model("Engagement"), ("id", "product__name")), store=("product__name", ))
115+
watson.register(app_config.get_model("App_Analysis"))
116+
watson.register(app_config.get_model("Vulnerability_Id"), store=("finding__test__engagement__product__name", ))
117+
118+
# YourModel = app_config.get_model("YourModel")
119+
# watson.register(YourModel)
120+
121+
113122
def get_model_fields_with_extra(model, extra_fields=()):
114123
return get_model_fields(get_model_default_fields(model), extra_fields)
115124

dojo/management/commands/complete_initialization.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,8 @@ def first_boot_setup(self) -> None:
138138
self.load_initial_fixtures()
139139
self.persist_jira_webhook_secret()
140140
self.load_extra_fixtures()
141-
self.install_watson()
141+
if settings.WATSON_SEARCH_ENABLED:
142+
self.install_watson()
142143
call_command("migrate_textquestions")
143144

144145
def create_admin_user(self) -> None:

dojo/search/views.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from django.conf import settings
77
from django.db.models import Q
8+
from django.http import HttpResponseGone
89
from django.shortcuts import render
910
from django.utils.translation import gettext as _
1011
from watson import search as watson
@@ -67,6 +68,12 @@ def simple_search(request):
6768
operators: {'tags': ['anchore'], 'vulnerability_id': ['CVE-2020-1234']}
6869
keywords: ['jquery']
6970
"""
71+
if not settings.WATSON_SEARCH_ENABLED:
72+
# Legacy watson-backed search is retired: return 410 rather than query
73+
# models that are no longer registered with watson. Pro overrides this
74+
# route with a redirect to its native search UI.
75+
return HttpResponseGone("Legacy search has been disabled.")
76+
7077
tests = None
7178
findings = None
7279
finding_templates = None

dojo/settings/settings.dist.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,13 @@
125125
DD_TAG_BULK_ADD_BATCH_SIZE=(int, 1000),
126126
# Tagulous slug truncate unique setting. Set to -1 to use tagulous internal default (5)
127127
DD_TAGULOUS_SLUG_TRUNCATE_UNIQUE=(int, -1),
128+
# Master switch for django-watson. When True (default) the post-save/pre-delete
129+
# search indexers are registered on 9 models (write-amplification on every save,
130+
# Finding imports especially) and the legacy /simple_search page (watson's only
131+
# reader) is served. When False, nothing is registered, /simple_search returns
132+
# 410 Gone, and installwatson is skipped. Pro ships this False: its native
133+
# Postgres search (pro/search/) fully replaces watson.
134+
DD_WATSON_SEARCH_ENABLED=(bool, True),
128135
# Batch size for async watson search-index update tasks. Also doubles as
129136
# the per-request intermediate-flush threshold: once the in-memory watson
130137
# context reaches this many pending objects mid-request,
@@ -848,6 +855,15 @@ def generate_url(scheme, double_slashes, user, password, host, port, path, param
848855
]
849856
MIDDLEWARE += WHITE_NOISE
850857

858+
# django-watson master switch (see DD_WATSON_SEARCH_ENABLED above). Pro ships this
859+
# off because its native Postgres search replaces watson; disabling strips the app
860+
# and its request-scoped indexing middleware so no post-save index writes happen
861+
# and installwatson is never needed.
862+
WATSON_SEARCH_ENABLED = env("DD_WATSON_SEARCH_ENABLED")
863+
if not WATSON_SEARCH_ENABLED:
864+
INSTALLED_APPS = tuple(app for app in INSTALLED_APPS if app != "watson")
865+
MIDDLEWARE = [m for m in MIDDLEWARE if m != "dojo.middleware.AsyncSearchContextMiddleware"]
866+
851867
EMAIL_CONFIG = env.email_url(
852868
"DD_EMAIL_URL", default="smtp://user@:password@localhost:25")
853869

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from django.contrib.auth import get_user_model
2+
from django.test import override_settings
3+
from django.urls import reverse
4+
5+
from dojo.models import System_Settings
6+
from unittests.dojo_test_case import DojoTestCase
7+
8+
User = get_user_model()
9+
10+
11+
# Regression: watson_searchentry grew to 44 GB while nothing in the Pro UI reads it.
12+
# The legacy /simple_search page is watson's only reader, so when watson is disabled
13+
# (DD_WATSON_SEARCH_ENABLED=False) the view must stop serving instead of calling
14+
# watson.filter/search on models that were never registered.
15+
#
16+
# V3_FEATURE_LOCATIONS is pinned off so this matches OSS CI defaults (the deprecated
17+
# Endpoint model raises under V3, which is unrelated to what we assert here).
18+
@override_settings(SECURE_SSL_REDIRECT=False, V3_FEATURE_LOCATIONS=False)
19+
class TestSimpleSearchWatsonToggle(DojoTestCase):
20+
21+
def setUp(self):
22+
System_Settings.objects.get_or_create(id=1)
23+
super().setUp()
24+
self.user = User.objects.create_superuser("watson-toggle-admin", "wt@example.com", "password")
25+
26+
def _get_simple_search(self):
27+
self.client.force_login(self.user)
28+
return self.client.get(reverse("simple_search"), {"query": "test"})
29+
30+
@override_settings(WATSON_SEARCH_ENABLED=False)
31+
def test_simple_search_gone_when_watson_disabled(self):
32+
response = self._get_simple_search()
33+
self.assertEqual(
34+
response.status_code, 410,
35+
msg=f"expected 410 Gone when WATSON_SEARCH_ENABLED=False, got {response.status_code}",
36+
)
37+
38+
@override_settings(WATSON_SEARCH_ENABLED=True)
39+
def test_simple_search_available_when_watson_enabled(self):
40+
response = self._get_simple_search()
41+
self.assertEqual(
42+
response.status_code, 200,
43+
msg=f"expected 200 when WATSON_SEARCH_ENABLED=True, got {response.status_code}",
44+
)

0 commit comments

Comments
 (0)