Skip to content

Commit bdb1805

Browse files
avara1986juanjuxchristophe-papazian
authored
fix(rcm): store payloads target files content (#5270)
Remote Configuration should be able to store previous products target file content due to ASM needs to update all content/rules, the new once and the previous once. further more, if a RC target file is disabled, RC should remove all configuration related to this target_file. (Note: this bug was introduced with the new products in 1.10 so there is no need to backport). e.g: ## Actual behavior: - Payload1: - Product `ASM_DD`. New target file `ASM_DD/file_12`. Content: `{"exclusions": [rule1]}` - ASM WAF updates with: `{"exclusions": [rule1]}` - Payload2: - Product `ASM_DD`. New target file `ASM_DD/file_34`. Content: `{"exclusions": [rule2]}` - ASM WAF updates with: `{"exclusions": [rule2]}` - Payload3: - Product `ASM_DD`. Disable Target File `ASM_DD/file_34`. Content: `{"exclusions": [rule2]}` - ASM WAF updates with: `{"exclusions": []}` ## With this PR the new behavior is: - Payload1: - Product `ASM_DD`. New target file `ASM_DD/file_12`. Content: `{"exclusions": [rule1]}` - ASM WAF updates with: `{"exclusions": [rule1]}` - Payload2: - Product `ASM_DD`. New target file `ASM_DD/file_34`. Content: `{"exclusions": [rule2]}` - ASM WAF updates with: `{"exclusions": [rule1, rule2]}` - Payload3: - Product `ASM_DD`. Disable Target File `ASM_DD/file_34`. Content: `{"exclusions": [rule2]}` - ASM WAF updates with: `{"exclusions": [rule1]}` ## Affected versions 1.10.rcX ## Checklist - [x] Change(s) are motivated and described in the PR description. - [x] Testing strategy is described if automated tests are not included in the PR. - [x] Risk is outlined (performance impact, potential for breakage, maintainability, etc). - [x] Change is maintainable (easy to change, telemetry, documentation). - [x] [Library release note guidelines](https://ddtrace.readthedocs.io/en/stable/contributing.html#Release-Note-Guidelines) are followed. - [x] Documentation is included (in-code, generated user docs, [public corp docs](https://github.com/DataDog/documentation/)). - [x] Author is aware of the performance implications of this PR as reported in the benchmarks PR comment. ## Reviewer Checklist - [x] Title is accurate. - [x] No unnecessary changes are introduced. - [x] Description motivates each change. - [x] Avoids breaking [API](https://ddtrace.readthedocs.io/en/stable/versioning.html#interfaces) changes unless absolutely necessary. - [x] Testing strategy adequately addresses listed risk(s). - [x] Change is maintainable (easy to change, telemetry, documentation). - [x] Release note makes sense to a user of the library. - [x] Reviewer is aware of, and discussed the performance implications of this PR as reported in the benchmarks PR comment. --------- Co-authored-by: Juanjo Alvarez Martinez <[email protected]> Co-authored-by: Christophe Papazian <[email protected]>
1 parent 5755849 commit bdb1805

File tree

5 files changed

+793
-55
lines changed

5 files changed

+793
-55
lines changed

ddtrace/appsec/_remoteconfiguration.py

Lines changed: 45 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
if TYPE_CHECKING: # pragma: no cover
2222
from typing import Any
23+
from typing import Dict
2324

2425
try:
2526
from typing import Literal
@@ -31,7 +32,6 @@
3132
from typing import Union
3233

3334
from ddtrace import Tracer
34-
from ddtrace.internal.remoteconfig.client import ConfigMetadata
3535

3636
log = get_logger(__name__)
3737

@@ -45,28 +45,31 @@ def enable_appsec_rc(test_tracer=None):
4545
else:
4646
tracer = test_tracer
4747

48-
appsec_features_callback = RCAppSecFeaturesCallBack(tracer)
49-
appsec_callback = RCAppSecCallBack(tracer)
48+
asm_features_callback = RCAppSecFeaturesCallBack(tracer)
49+
asm_dd_callback = RCASMDDCallBack(tracer)
50+
asm_callback = RCAppSecCallBack(tracer)
5051

5152
if _appsec_rc_features_is_enabled():
5253
from ddtrace.internal.remoteconfig import RemoteConfig
5354

54-
RemoteConfig.register(PRODUCTS.ASM_FEATURES, appsec_features_callback)
55+
RemoteConfig.register(PRODUCTS.ASM_FEATURES, asm_features_callback)
5556

5657
if tracer._appsec_enabled:
5758
from ddtrace.internal.remoteconfig import RemoteConfig
5859

59-
RemoteConfig.register(PRODUCTS.ASM_DATA, appsec_callback) # IP Blocking
60-
RemoteConfig.register(PRODUCTS.ASM, appsec_callback) # Exclusion Filters & Custom Rules
61-
RemoteConfig.register(PRODUCTS.ASM_DD, appsec_callback) # DD Rules
60+
RemoteConfig.register(PRODUCTS.ASM_DATA, asm_callback) # IP Blocking
61+
RemoteConfig.register(PRODUCTS.ASM, asm_callback) # Exclusion Filters & Custom Rules
62+
RemoteConfig.register(PRODUCTS.ASM_DD, asm_dd_callback) # DD Rules
6263

6364

64-
def _add_rules_to_list(features, feature, message, rule_list):
65-
# type: (Mapping[str, Any], str, str, list[Any]) -> None
66-
rules = features.get(feature, [])
67-
if rules:
65+
def _add_rules_to_list(features, feature, message, ruleset):
66+
# type: (Mapping[str, Any], str, str, Dict[str, Any]) -> None
67+
rules = features.get(feature, None)
68+
if rules is not None:
6869
try:
69-
rule_list += rules
70+
if ruleset.get(feature) is None:
71+
ruleset[feature] = []
72+
ruleset[feature] += rules
7073
log.debug("Reloading Appsec %s: %s", message, rules)
7174
except JSONDecodeError:
7275
log.error("ERROR Appsec %s: invalid JSON content from remote configuration", message)
@@ -75,18 +78,32 @@ def _add_rules_to_list(features, feature, message, rule_list):
7578
def _appsec_rules_data(tracer, features):
7679
# type: (Tracer, Mapping[str, Any]) -> bool
7780
if features and tracer._appsec_processor:
78-
ruleset = {"rules": [], "rules_data": [], "exclusions": [], "rules_override": []} # type: dict[str, list[Any]]
79-
_add_rules_to_list(features, "rules_data", "rules data", ruleset["rules_data"])
80-
_add_rules_to_list(features, "custom_rules", "custom rules", ruleset["rules"])
81-
_add_rules_to_list(features, "rules", "Datadog rules", ruleset["rules"])
82-
_add_rules_to_list(features, "exclusions", "exclusion filters", ruleset["exclusions"])
83-
_add_rules_to_list(features, "rules_override", "rules override", ruleset["rules_override"])
84-
if any(ruleset.values()):
85-
return tracer._appsec_processor._update_rules({k: v for k, v in ruleset.items() if v})
81+
ruleset = {
82+
"rules": None,
83+
"rules_data": None,
84+
"exclusions": None,
85+
"rules_override": None,
86+
} # type: dict[str, Optional[list[Any]]]
87+
_add_rules_to_list(features, "rules_data", "rules data", ruleset)
88+
_add_rules_to_list(features, "custom_rules", "custom rules", ruleset)
89+
_add_rules_to_list(features, "rules", "Datadog rules", ruleset)
90+
_add_rules_to_list(features, "exclusions", "exclusion filters", ruleset)
91+
_add_rules_to_list(features, "rules_override", "rules override", ruleset)
92+
return tracer._appsec_processor._update_rules({k: v for k, v in ruleset.items() if v is not None})
8693

8794
return False
8895

8996

97+
class RCASMDDCallBack(RemoteConfigCallBack):
98+
def __init__(self, tracer):
99+
# type: (Tracer) -> None
100+
self.tracer = tracer
101+
102+
def __call__(self, metadata, features):
103+
if features is not None:
104+
_appsec_rules_data(self.tracer, features)
105+
106+
90107
class RCAppSecFeaturesCallBack(RemoteConfigCallBack):
91108
def __init__(self, tracer):
92109
# type: (Tracer) -> None
@@ -126,10 +143,11 @@ def _appsec_1click_activation(self, features):
126143
log.debug("Updating ASM Remote Configuration ASM_FEATURES: %s", rc_appsec_enabled)
127144

128145
if rc_appsec_enabled:
129-
appsec_callback = RCAppSecCallBack(self.tracer)
130-
RemoteConfig.register(PRODUCTS.ASM_DATA, appsec_callback) # IP Blocking
131-
RemoteConfig.register(PRODUCTS.ASM, appsec_callback) # Exclusion Filters & Custom Rules
132-
RemoteConfig.register(PRODUCTS.ASM_DD, appsec_callback) # DD Rules
146+
asm_dd_callback = RCASMDDCallBack(self.tracer)
147+
asm_callback = RCAppSecCallBack(self.tracer)
148+
RemoteConfig.register(PRODUCTS.ASM_DATA, asm_callback) # IP Blocking
149+
RemoteConfig.register(PRODUCTS.ASM, asm_callback) # Exclusion Filters & Custom Rules
150+
RemoteConfig.register(PRODUCTS.ASM_DD, asm_dd_callback) # DD Rules
133151
if not self.tracer._appsec_enabled:
134152
self.tracer.configure(appsec_enabled=True)
135153
else:
@@ -146,13 +164,12 @@ def _appsec_1click_activation(self, features):
146164

147165

148166
class RCAppSecCallBack(RemoteConfigCallBackAfterMerge):
149-
configs = {}
150-
151167
def __init__(self, tracer):
152168
# type: (Tracer) -> None
169+
super(RCAppSecCallBack, self).__init__()
153170
self.tracer = tracer
154171

155-
def __call__(self, metadata, features):
156-
# type: (Optional[ConfigMetadata], Any) -> None
172+
def __call__(self, target, features):
173+
# type: (str, Any) -> None
157174
if features is not None:
158175
_appsec_rules_data(self.tracer, features)

ddtrace/internal/remoteconfig/client.py

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -154,17 +154,39 @@ def __call__(self, metadata, config):
154154
pass
155155

156156

157-
class RemoteConfigCallBackAfterMerge(RemoteConfigCallBack):
157+
class RemoteConfigCallBackAfterMerge(six.with_metaclass(abc.ABCMeta)):
158158
configs = {} # type: Dict[str, Any]
159159

160-
def append(self, config):
161-
self.configs.update(config)
160+
@abc.abstractmethod
161+
def __call__(self, target, config):
162+
# type: (str, Any) -> None
163+
pass
164+
165+
def append(self, target, config):
166+
if not self.configs.get(target):
167+
self.configs[target] = {}
168+
if config is False:
169+
# Remove old config from the configs dict. _remove_previously_applied_configurations function should
170+
# call to this method
171+
del self.configs[target]
172+
elif config is not None:
173+
# Append the new config to the configs dict. _load_new_configurations function should
174+
# call to this method
175+
if isinstance(config, dict):
176+
self.configs[target].update(config)
177+
else:
178+
raise ValueError("target %s config %s has type of %s" % (target, config, type(config)))
162179

163180
def dispatch(self):
164-
try:
165-
self.__call__(None, self.configs)
166-
finally:
167-
self.configs = {}
181+
config_result = {}
182+
for target, config in self.configs.items():
183+
for key, value in config.items():
184+
if isinstance(value, list):
185+
config_result[key] = config_result.get(key, []) + value
186+
else:
187+
raise ValueError("target %s key %s has type of %s" % (target, key, type(value)))
188+
if config_result:
189+
self.__call__("", config_result)
168190

169191

170192
class RemoteConfigClient(object):
@@ -351,29 +373,47 @@ def _process_targets(self, payload):
351373
backend_state = signed.custom.get("opaque_backend_state")
352374
return signed.version, backend_state, targets
353375

376+
@staticmethod
377+
def _apply_callback(list_callbacks, callback, config_content, target, config):
378+
# type: (List[RemoteConfigCallBackAfterMerge], Any, Any, str, ConfigMetadata) -> None
379+
if isinstance(callback, RemoteConfigCallBackAfterMerge):
380+
callback.append(target, config_content)
381+
if callback not in list_callbacks and not any(
382+
filter(lambda x: isinstance(x, type(callback)), list_callbacks)
383+
):
384+
list_callbacks.append(callback)
385+
else:
386+
callback(config, config_content)
387+
354388
def _remove_previously_applied_configurations(self, applied_configs, client_configs, targets):
355389
# type: (AppliedConfigType, TargetsType, TargetsType) -> None
390+
list_callbacks = [] # type: List[RemoteConfigCallBackAfterMerge]
356391
for target, config in self._applied_configs.items():
357-
callback_action = None
358392
if target in client_configs and targets.get(target) == config:
359393
# The configuration has not changed.
360394
applied_configs[target] = config
361395
continue
362396
elif target not in client_configs:
363397
log.debug("Disable configuration: %s", target)
364398
callback_action = False
399+
else:
400+
continue
365401

366402
callback = self._products.get(config.product_name)
367403
if callback:
368404
try:
369-
callback(config, callback_action)
405+
log.debug("Disable configuration 2: %s. ", target)
406+
self._apply_callback(list_callbacks, callback, callback_action, target, config)
370407
except Exception:
371408
log.debug("error while removing product %s config %r", config.product_name, config)
372409
continue
373410

411+
for callback_to_dispach in list_callbacks:
412+
callback_to_dispach.dispatch()
413+
374414
def _load_new_configurations(self, applied_configs, client_configs, payload):
375415
# type: (AppliedConfigType, TargetsType, AgentPayload) -> None
376-
list_callbacks = []
416+
list_callbacks = [] # type: List[RemoteConfigCallBackAfterMerge]
377417
for target, config in client_configs.items():
378418
callback = self._products.get(config.product_name)
379419
if callback:
@@ -387,21 +427,17 @@ def _load_new_configurations(self, applied_configs, client_configs, payload):
387427

388428
try:
389429
log.debug("Load new configuration: %s. content ", target)
390-
if isinstance(callback, RemoteConfigCallBackAfterMerge):
391-
callback.append(config_content)
392-
if callback not in list_callbacks:
393-
list_callbacks.append(callback)
394-
else:
395-
callback(config, config_content)
430+
self._apply_callback(list_callbacks, callback, config_content, target, config)
396431
except Exception:
397432
log.debug(
398433
"Failed to load configuration %s for product %r", config, config.product_name, exc_info=True
399434
)
400435
continue
401436
else:
402437
applied_configs[target] = config
403-
for callback in list_callbacks:
404-
callback.dispatch()
438+
439+
for callback_to_dispach in list_callbacks:
440+
callback_to_dispach.dispatch()
405441

406442
def _add_apply_config_to_cache(self):
407443
if self._applied_configs:

0 commit comments

Comments
 (0)