From fa792f1285aa2d2a23db6973f3796aee0aa97aa1 Mon Sep 17 00:00:00 2001 From: "alejandro.gonzalez" Date: Wed, 15 Oct 2025 14:25:52 +0200 Subject: [PATCH 1/2] WIP --- manifests/cpp_nginx.yml | 5 + manifests/dotnet.yml | 5 + manifests/golang.yml | 5 + manifests/java.yml | 13 + manifests/nodejs.yml | 5 + manifests/php.yml | 5 + manifests/python.yml | 5 + manifests/ruby.yml | 5 + tests/appsec/blocking_rule.json | 35 +++ tests/appsec/waf/blocked.v3.min.html | 1 + tests/appsec/waf/blocked.v3.min.json | 1 + tests/appsec/waf/test_blocking.py | 76 +++++- .../waf/test_blocking_security_response_id.py | 226 ++++++++++++++++++ utils/_features.py | 8 + 14 files changed, 389 insertions(+), 6 deletions(-) create mode 100644 tests/appsec/waf/blocked.v3.min.html create mode 100644 tests/appsec/waf/blocked.v3.min.json create mode 100644 tests/appsec/waf/test_blocking_security_response_id.py diff --git a/manifests/cpp_nginx.yml b/manifests/cpp_nginx.yml index 08e27566d1f..6fa0d890cd1 100644 --- a/manifests/cpp_nginx.yml +++ b/manifests/cpp_nginx.yml @@ -65,6 +65,11 @@ tests/: Test_Blocking: v1.2.0 Test_Blocking_strip_response_headers: irrelevant (no response headers on 1st waf run, which is where blocking is possible) Test_CustomBlockingResponse: v1.2.0 + test_blocking_security_response_id.py: + Test_SecurityResponseId_Custom_Redirect: missing_feature + Test_SecurityResponseId_HTML_Response: missing_feature + Test_SecurityResponseId_In_Span_Triggers: missing_feature + Test_SecurityResponseId_JSON_Response: missing_feature test_custom_rules.py: Test_CustomRules: v1.2.0 test_exclusions.py: diff --git a/manifests/dotnet.yml b/manifests/dotnet.yml index 42b39383d71..982dc8b1646 100644 --- a/manifests/dotnet.yml +++ b/manifests/dotnet.yml @@ -302,6 +302,11 @@ tests/: Test_Blocking: v2.27.0 Test_Blocking_strip_response_headers: missing_feature Test_CustomBlockingResponse: v3.10.0 + test_blocking_security_response_id.py: + Test_SecurityResponseId_Custom_Redirect: missing_feature + Test_SecurityResponseId_HTML_Response: missing_feature + Test_SecurityResponseId_In_Span_Triggers: missing_feature + Test_SecurityResponseId_JSON_Response: missing_feature test_custom_rules.py: Test_CustomRules: v2.30.0 test_exclusions.py: diff --git a/manifests/golang.yml b/manifests/golang.yml index 91c97599777..2845ce1d6b0 100644 --- a/manifests/golang.yml +++ b/manifests/golang.yml @@ -350,6 +350,11 @@ tests/: Test_Blocking: v1.50.0-rc.1 Test_Blocking_strip_response_headers: missing_feature Test_CustomBlockingResponse: v1.63.0 + test_blocking_security_response_id.py: + Test_SecurityResponseId_Custom_Redirect: missing_feature + Test_SecurityResponseId_HTML_Response: missing_feature + Test_SecurityResponseId_In_Span_Triggers: missing_feature + Test_SecurityResponseId_JSON_Response: missing_feature test_custom_rules.py: Test_CustomRules: v1.51.0 test_exclusions.py: diff --git a/manifests/java.yml b/manifests/java.yml index ae112981ab5..ff6656b5e50 100644 --- a/manifests/java.yml +++ b/manifests/java.yml @@ -1148,6 +1148,19 @@ tests/: akka-http: v1.22.0 play: v1.22.0 spring-boot-3-native: irrelevant (GraalVM. Tracing support only) + test_blocking_security_response_id.py: + Test_SecurityResponseId_Custom_Redirect: + '*': missing_feature + spring-boot-3-native: irrelevant (GraalVM. Tracing support only) + Test_SecurityResponseId_HTML_Response: + '*': missing_feature + spring-boot-3-native: irrelevant (GraalVM. Tracing support only) + Test_SecurityResponseId_In_Span_Triggers: + '*': missing_feature + spring-boot-3-native: irrelevant (GraalVM. Tracing support only) + Test_SecurityResponseId_JSON_Response: + '*': missing_feature + spring-boot-3-native: irrelevant (GraalVM. Tracing support only) test_custom_rules.py: Test_CustomRules: '*': v1.51.0 diff --git a/manifests/nodejs.yml b/manifests/nodejs.yml index 818f2407086..6cf4494cce0 100644 --- a/manifests/nodejs.yml +++ b/manifests/nodejs.yml @@ -768,6 +768,11 @@ tests/: Test_Blocking: *ref_3_19_0 Test_Blocking_strip_response_headers: *ref_5_17_0 Test_CustomBlockingResponse: *ref_5_15_0 + test_blocking_security_response_id.py: + Test_SecurityResponseId_Custom_Redirect: missing_feature + Test_SecurityResponseId_HTML_Response: missing_feature + Test_SecurityResponseId_In_Span_Triggers: missing_feature + Test_SecurityResponseId_JSON_Response: missing_feature test_custom_rules.py: Test_CustomRules: '*': *ref_4_1_0 diff --git a/manifests/php.yml b/manifests/php.yml index 58c60f223a0..5e21c05c2c0 100644 --- a/manifests/php.yml +++ b/manifests/php.yml @@ -312,6 +312,11 @@ tests/: Test_Blocking: missing_feature # v0.86.0 Test_Blocking_strip_response_headers: missing_feature Test_CustomBlockingResponse: missing_feature # v0.86.0 + test_blocking_security_response_id.py: + Test_SecurityResponseId_Custom_Redirect: missing_feature + Test_SecurityResponseId_HTML_Response: missing_feature + Test_SecurityResponseId_In_Span_Triggers: missing_feature + Test_SecurityResponseId_JSON_Response: missing_feature test_custom_rules.py: Test_CustomRules: v0.87.2 test_exclusions.py: diff --git a/manifests/python.yml b/manifests/python.yml index bb35a39264e..9705d3f2fcd 100644 --- a/manifests/python.yml +++ b/manifests/python.yml @@ -452,6 +452,11 @@ tests/: Test_CustomBlockingResponse: '*': v1.20.0 fastapi: v2.4.0 + test_blocking_security_response_id.py: + Test_SecurityResponseId_Custom_Redirect: missing_feature + Test_SecurityResponseId_HTML_Response: missing_feature + Test_SecurityResponseId_In_Span_Triggers: missing_feature + Test_SecurityResponseId_JSON_Response: missing_feature test_custom_rules.py: Test_CustomRules: '*': v1.16.1 diff --git a/manifests/ruby.yml b/manifests/ruby.yml index 50cc5c540c0..b2c9a2bd6f3 100644 --- a/manifests/ruby.yml +++ b/manifests/ruby.yml @@ -345,6 +345,11 @@ tests/: Test_Blocking: v1.11.0 Test_Blocking_strip_response_headers: v1.13.0 Test_CustomBlockingResponse: v1.15.0 + test_blocking_security_response_id.py: + Test_SecurityResponseId_Custom_Redirect: missing_feature + Test_SecurityResponseId_HTML_Response: missing_feature + Test_SecurityResponseId_In_Span_Triggers: missing_feature + Test_SecurityResponseId_JSON_Response: missing_feature test_custom_rules.py: Test_CustomRules: v1.12.0 test_exclusions.py: diff --git a/tests/appsec/blocking_rule.json b/tests/appsec/blocking_rule.json index ab95f3805a8..ed816b8ced2 100644 --- a/tests/appsec/blocking_rule.json +++ b/tests/appsec/blocking_rule.json @@ -189,6 +189,33 @@ "block4" ] }, + { + "id": "canary_rule5", + "name": "Canary 5", + "tags": { + "type": "security_scanner", + "category": "attack_attempt" + }, + "conditions": [ + { + "parameters": { + "inputs": [ + { + "address": "server.request.headers.no_cookies", + "key_path": [ + "user-agent" + ] + } + ], + "regex": "^Canary\\/v5" + }, + "operator": "match_regex" + } + ], + "on_match": [ + "block5" + ] + }, { "id": "tst-037-009", "name": "Test block on response header", @@ -857,6 +884,14 @@ "status_code": 303, "location": "" } + }, + { + "id": "block5", + "type": "redirect_request", + "parameters": { + "status_code": 301, + "location": "/you-have-been-blocked?security_response_id=[security_response_id]" + } } ], "rules_data": [ diff --git a/tests/appsec/waf/blocked.v3.min.html b/tests/appsec/waf/blocked.v3.min.html new file mode 100644 index 00000000000..cc1c0acd0bd --- /dev/null +++ b/tests/appsec/waf/blocked.v3.min.html @@ -0,0 +1 @@ +You've been blocked

Sorry, you cannot access this page. Please contact the customer service team.

Security Response ID: 00000000-0000-4000-8000-000000000000

diff --git a/tests/appsec/waf/blocked.v3.min.json b/tests/appsec/waf/blocked.v3.min.json new file mode 100644 index 00000000000..45e3938dc4e --- /dev/null +++ b/tests/appsec/waf/blocked.v3.min.json @@ -0,0 +1 @@ +{"errors":[{"title":"You've been blocked","detail":"Sorry, you cannot access this page. Please contact the customer service team. Security provided by Datadog."}],"security_response_id":"00000000-0000-4000-8000-000000000000"} diff --git a/tests/appsec/waf/test_blocking.py b/tests/appsec/waf/test_blocking.py index a68bf0966a4..937b297ffe7 100644 --- a/tests/appsec/waf/test_blocking.py +++ b/tests/appsec/waf/test_blocking.py @@ -2,10 +2,17 @@ from utils import interfaces, bug, scenarios, weblog, rfc, missing_feature, flaky, features from utils._context.core import context +from .test_blocking_security_response_id import ( + is_valid_uuid4, + extract_security_response_id_from_json, + extract_security_response_id_from_html, +) BLOCK_TEMPLATE_JSON_MIN_V1 = "blocked.v1.min.json" BLOCK_TEMPLATE_HTML_MIN_V2 = "blocked.v2.min.html" +BLOCK_TEMPLATE_JSON_MIN_V3 = "blocked.v3.min.json" +BLOCK_TEMPLATE_HTML_MIN_V3 = "blocked.v3.min.html" def _read_file(file_path: str) -> str: @@ -13,6 +20,40 @@ def _read_file(file_path: str) -> str: return file.read() +def _is_valid_json_v3_template(body: str) -> bool: + """Check if body matches v3 JSON template with valid dynamic security_response_id + + RFC-1070: Uses the actual security_response_id from the response for validation + """ + # Extract and validate security_response_id from actual response + security_response_id = extract_security_response_id_from_json(body) + if security_response_id is None or not is_valid_uuid4(security_response_id): + return False + + # Build expected response by injecting the actual security_response_id into the template + v3_template = _read_file(BLOCK_TEMPLATE_JSON_MIN_V3).rstrip() + expected_response = v3_template.replace("00000000-0000-4000-8000-000000000000", security_response_id) + + return body.rstrip() == expected_response + + +def _is_valid_html_v3_template(body: str) -> bool: + """Check if body matches v3 HTML template with valid dynamic security_response_id + + RFC-1070: Uses the actual security_response_id from the response for validation + """ + # Extract and validate security_response_id from actual response + security_response_id = extract_security_response_id_from_html(body) + if security_response_id is None or not is_valid_uuid4(security_response_id): + return False + + # Build expected response by injecting the actual security_response_id into the template + v3_template = _read_file(BLOCK_TEMPLATE_HTML_MIN_V3).rstrip() + expected_response = v3_template.replace("00000000-0000-4000-8000-000000000000", security_response_id) + + return body.rstrip() == expected_response + + def assert_valid_html_blocked_template(body: str) -> None: """Returns true if body is a valid HTML response on a blocked requests""" @@ -24,7 +65,8 @@ def assert_valid_html_blocked_template(body: str) -> None: _read_file(BLOCK_TEMPLATE_HTML_MIN_V2), } - assert body in valid_templates + # Check for v3 template with dynamic security_response_id + assert body in valid_templates or _is_valid_html_v3_template(body) def assert_valid_json_blocked_template(body: str) -> None: @@ -38,7 +80,9 @@ def assert_valid_json_blocked_template(body: str) -> None: _read_file(BLOCK_TEMPLATE_JSON_MIN_V1), _read_file(BLOCK_TEMPLATE_JSON_MIN_V1).rstrip(), } - assert body in valid_templates + + # Check for v3 template with dynamic security_response_id + assert body in valid_templates or _is_valid_json_v3_template(body) HTML_CONTENT_TYPES = {"text/html", "text/html; charset=utf-8", "text/html;charset=utf-8"} @@ -182,10 +226,20 @@ def setup_json_template_v1(self): @missing_feature(context.library < "python@2.11.0.dev") @missing_feature(library="ruby") def test_json_template_v1(self): - """HTML block template is v1 minified""" + """JSON block template is v1 minified (or v3 with security_response_id)""" assert self.r_json_v1.status_code == 403 assert self.r_json_v1.headers.get("content-type", "").lower() in JSON_CONTENT_TYPES - assert self.r_json_v1.text.rstrip() == _read_file(BLOCK_TEMPLATE_JSON_MIN_V1).rstrip() + + # Accept v1 template without security_response_id or v3 template with security_response_id + response_text = self.r_json_v1.text.rstrip() + v1_template = _read_file(BLOCK_TEMPLATE_JSON_MIN_V1).rstrip() + + # Check if it's v1 template + if response_text == v1_template: + return + + # Check if it's v3 template with valid security_response_id + assert _is_valid_json_v3_template(self.r_json_v1.text), "Response doesn't match v1 or v3 template" def setup_html_template_v2(self): self.r_html_v2 = weblog.get("/waf/", headers={"User-Agent": "Arachni/v1", "Accept": "text/html"}) @@ -197,10 +251,20 @@ def setup_html_template_v2(self): @missing_feature(context.library < "python@2.11.0.dev") @missing_feature(library="ruby") def test_html_template_v2(self): - """HTML block template is v2 minified""" + """HTML block template is v2 minified (or v3 with security_response_id)""" assert self.r_html_v2.status_code == 403 assert self.r_html_v2.headers.get("content-type", "").lower() in HTML_CONTENT_TYPES - assert self.r_html_v2.text == _read_file(BLOCK_TEMPLATE_HTML_MIN_V2) + + # Accept v2 template without security_response_id or v3 template with security_response_id + response_text = self.r_html_v2.text + v2_template = _read_file(BLOCK_TEMPLATE_HTML_MIN_V2) + + # Check if it's v2 template + if response_text == v2_template: + return + + # Check if it's v3 template with valid security_response_id + assert _is_valid_html_v3_template(self.r_html_v2.text), "Response doesn't match v2 or v3 template" @rfc("https://datadoghq.atlassian.net/wiki/spaces/APS/pages/2705464728/Blocking#Stripping-response-headers") diff --git a/tests/appsec/waf/test_blocking_security_response_id.py b/tests/appsec/waf/test_blocking_security_response_id.py new file mode 100644 index 00000000000..70ce2c9b4f7 --- /dev/null +++ b/tests/appsec/waf/test_blocking_security_response_id.py @@ -0,0 +1,226 @@ +# Unless explicitly stated otherwise all files in this repository are licensed under the the Apache License Version 2.0. +# This product includes software developed at Datadog (https://www.datadoghq.com/). +# Copyright 2021 Datadog, Inc. + +import json +import re +from urllib.parse import urlparse, parse_qs + +from utils import scenarios, weblog, rfc, features, interfaces + + +def is_valid_uuid4(uuid_string): + """Validate UUID format: 8-4-4-4-12 hex digits""" + if not uuid_string or not isinstance(uuid_string, str): + return False + + uuid_pattern = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE) + return bool(uuid_pattern.match(uuid_string)) + + +def extract_security_response_id_from_json(response_body): + """Extract security_response_id from JSON blocking response + + RFC-1070: Libraries emit security_response_id in blocking responses + Structure: {"errors": [...], "security_response_id": "uuid"} + """ + try: + data = json.loads(response_body) + return data.get("security_response_id") + except (json.JSONDecodeError, KeyError, TypeError): + pass + return None + + +def extract_security_response_id_from_html(response_body): + """Extract security_response_id from HTML blocking response + + RFC-1070: Libraries emit security_response_id in blocking responses + Expected format:

Security Response ID: {uuid}

+ """ + if not response_body: + return None + + security_response_id_pattern = re.compile( + r'Security\s+Response\s+ID:\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})

', + re.IGNORECASE, + ) + match = security_response_id_pattern.search(response_body) + if match: + return match.group(1) + + return None + + +def extract_security_response_id_from_redirect_url(location_url): + """Extract security_response_id from custom redirect URL query parameters + + RFC-1070: Libraries emit security_response_id in blocking responses + Expected format: http://example.com/redirect?security_response_id={uuid} + """ + if not location_url: + return None + + try: + parsed_url = urlparse(location_url) + query_params = parse_qs(parsed_url.query) + security_response_id_list = query_params.get("security_response_id", []) + if security_response_id_list: + return security_response_id_list[0] + return None + except Exception: + return None + + +@rfc("https://datadoghq.atlassian.net/wiki/spaces/APS/pages/4235215165/RFC-1070+Blocking+Response+Unique+Identifier") +@features.blocking_response_id +@scenarios.appsec_blocking +class Test_SecurityResponseId_JSON_Response: + """Test that security_response_id is present in JSON blocking responses""" + + def setup_security_response_id_in_json_response(self): + """Trigger a blocking request with JSON response""" + self.r_json = weblog.get("/waf/", headers={"User-Agent": "Arachni/v1", "Accept": "application/json"}) + + def test_security_response_id_in_json_response(self): + """Verify security_response_id is present in JSON response and is a valid UUIDv4""" + assert self.r_json.status_code == 403, f"Expected 403, got {self.r_json.status_code}" + + # Extract security_response_id from response + security_response_id = extract_security_response_id_from_json(self.r_json.text) + assert security_response_id is not None, f"security_response_id not found in JSON response: {self.r_json.text}" + + # Validate UUID format + assert is_valid_uuid4( + security_response_id + ), f"security_response_id is not a valid UUIDv4: {security_response_id}" + + def setup_security_response_id_uniqueness(self): + """Make multiple blocking requests to test uniqueness""" + self.r1 = weblog.get("/waf/", headers={"User-Agent": "Arachni/v1", "Accept": "application/json"}) + self.r2 = weblog.get("/waf/", headers={"User-Agent": "Arachni/v1", "Accept": "application/json"}) + self.r3 = weblog.get("/waf/", headers={"User-Agent": "Arachni/v1", "Accept": "application/json"}) + + def test_security_response_id_uniqueness(self): + """Verify each blocking request gets a unique security_response_id""" + assert self.r1.status_code == 403 + assert self.r2.status_code == 403 + assert self.r3.status_code == 403 + + security_response_id_1 = extract_security_response_id_from_json(self.r1.text) + security_response_id_2 = extract_security_response_id_from_json(self.r2.text) + security_response_id_3 = extract_security_response_id_from_json(self.r3.text) + + assert security_response_id_1 is not None, "security_response_id not found in first response" + assert security_response_id_2 is not None, "security_response_id not found in second response" + assert security_response_id_3 is not None, "security_response_id not found in third response" + + # All security_response_ids should be unique + assert ( + security_response_id_1 != security_response_id_2 + ), f"security_response_ids are not unique: {security_response_id_1} == {security_response_id_2}" + assert ( + security_response_id_1 != security_response_id_3 + ), f"security_response_ids are not unique: {security_response_id_1} == {security_response_id_3}" + assert ( + security_response_id_2 != security_response_id_3 + ), f"security_response_ids are not unique: {security_response_id_2} == {security_response_id_3}" + + +@rfc("https://datadoghq.atlassian.net/wiki/spaces/APS/pages/4235215165/RFC-1070+Blocking+Response+Unique+Identifier") +@features.blocking_response_id +@scenarios.appsec_blocking +class Test_SecurityResponseId_HTML_Response: + """Test that security_response_id is present in HTML blocking responses""" + + def setup_security_response_id_in_html_response(self): + """Trigger a blocking request with HTML response""" + self.r_html = weblog.get("/waf/", headers={"User-Agent": "Arachni/v1", "Accept": "text/html"}) + + def test_security_response_id_in_html_response(self): + """Verify security_response_id is present in HTML response and is a valid UUIDv4""" + assert self.r_html.status_code == 403, f"Expected 403, got {self.r_html.status_code}" + + # Extract security_response_id from HTML response + security_response_id = extract_security_response_id_from_html(self.r_html.text) + assert security_response_id is not None, f"security_response_id not found in HTML response: {self.r_html.text}" + + # Validate UUID format + assert is_valid_uuid4( + security_response_id + ), f"security_response_id is not a valid UUIDv4: {security_response_id}" + + +@rfc("https://datadoghq.atlassian.net/wiki/spaces/APS/pages/4235215165/RFC-1070+Blocking+Response+Unique+Identifier") +@features.blocking_response_id +@scenarios.appsec_blocking +class Test_SecurityResponseId_Custom_Redirect: + """Test that security_response_id can optionally be present in custom redirect URLs + + Note: This is an optional feature in RFC-1070. Tracers that implement + custom redirect blocking actions CAN include the security_response_id as a query parameter. + """ + + def setup_security_response_id_in_redirect_url(self): + """Trigger a blocking request that should redirect with security_response_id""" + # Request with custom redirect blocking action that includes security_response_id in URL + self.r_redirect = weblog.get("/waf/", headers={"User-Agent": "Canary/v5"}, allow_redirects=False) + + def test_security_response_id_in_redirect_url(self): + """Verify security_response_id is present in redirect URL and is a valid UUIDv4""" + assert self.r_redirect.status_code == 301, f"Expected 301, got {self.r_redirect.status_code}" + + # Extract Location header + location = self.r_redirect.headers.get("Location") + assert location is not None, "Redirect response missing Location header" + + # Extract security_response_id from URL query parameters + security_response_id = extract_security_response_id_from_redirect_url(location) + assert security_response_id is not None, f"security_response_id not found in redirect URL: {location}" + + # Validate UUID format + assert is_valid_uuid4( + security_response_id + ), f"security_response_id is not a valid UUIDv4: {security_response_id}" + + +@rfc("https://datadoghq.atlassian.net/wiki/spaces/APS/pages/4235215165/RFC-1070+Blocking+Response+Unique+Identifier") +@features.blocking_response_id +@scenarios.appsec_blocking +class Test_SecurityResponseId_In_Span_Triggers: + """Test that security_response_id is present in AppSec span triggers during blocking events + + RFC-1070: The WAF provides security_response_id to tracers, which emit it in + the triggers array of the AppSec span data (_dd.appsec.json). + """ + + def setup_security_response_id_in_span_trigger(self): + """Trigger a blocking request to validate security_response_id in span""" + self.r = weblog.get("/waf/", headers={"User-Agent": "Arachni/v1", "Accept": "application/json"}) + + def test_security_response_id_in_span_trigger(self): + """Verify security_response_id is present in span trigger and is a valid UUIDv4""" + assert self.r.status_code == 403, f"Expected 403, got {self.r.status_code}" + + # Get the root span and extract appsec data + span = interfaces.library.get_root_span(request=self.r) + meta = span.get("meta", {}) + meta_struct = span.get("meta_struct", {}) + + # Extract appsec data (support both formats: meta_struct.appsec or meta._dd.appsec.json) + appsec = meta.get("_dd.appsec.json", {}) or meta_struct.get("appsec", {}) + assert appsec, "No appsec data found in span" + + # Validate triggers structure exists + triggers = appsec.get("triggers", []) + assert len(triggers) > 0, "No triggers found in appsec data" + + # Extract security_response_id from first trigger + trigger = triggers[0] + security_response_id = trigger.get("security_response_id") + assert security_response_id is not None, f"security_response_id not found in trigger: {trigger}" + + # Validate UUID format + assert is_valid_uuid4( + security_response_id + ), f"security_response_id is not a valid UUIDv4: {security_response_id}" diff --git a/utils/_features.py b/utils/_features.py index 132d4022840..ba91f3734a5 100644 --- a/utils/_features.py +++ b/utils/_features.py @@ -2599,5 +2599,13 @@ def llm_observability(test_object): """ return _mark_test_object(test_object, feature_id=497, owner=_Owner.ml_observability) + @staticmethod + def blocking_response_id(test_object): + """Data integrity + + https://feature-parity.us1.prod.dog/#/?feature=493 + """ + return _mark_test_object(test_object, feature_id=493, owner=_Owner.agent_apm) + features = _Features() From 164741e2ac316f642e1a63bb9d7ba68dedaaf4bc Mon Sep 17 00:00:00 2001 From: "alejandro.gonzalez" Date: Wed, 29 Oct 2025 13:56:18 +0100 Subject: [PATCH 2/2] WIP --- utils/_features.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/utils/_features.py b/utils/_features.py index ba91f3734a5..6c1ae1a603c 100644 --- a/utils/_features.py +++ b/utils/_features.py @@ -2599,7 +2599,7 @@ def llm_observability(test_object): """ return _mark_test_object(test_object, feature_id=497, owner=_Owner.ml_observability) - @staticmethod + @staticmethod def blocking_response_id(test_object): """Data integrity