Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add better support for the new RC API #2757

Merged
merged 19 commits into from
Jul 18, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
bd6d97d
add file deletion and state/version tracking
christophe-papazian Jul 16, 2024
d07a3b8
Merge remote-tracking branch 'origin/main' into christophe-papazian/u…
christophe-papazian Jul 16, 2024
7a8b392
fix other tests
christophe-papazian Jul 16, 2024
d474ed8
fix more tests
christophe-papazian Jul 16, 2024
597405b
update documentation
christophe-papazian Jul 16, 2024
1cca94c
update manifests, use plain dict for current_states for replay
christophe-papazian Jul 16, 2024
c83bdaf
fix test
christophe-papazian Jul 16, 2024
7dfd66a
Merge branch 'main' into christophe-papazian/upgrade_rc_api
christophe-papazian Jul 16, 2024
95ff85c
Merge branch 'main' into christophe-papazian/upgrade_rc_api
christophe-papazian Jul 16, 2024
05182dc
Merge remote-tracking branch 'origin/main' into christophe-papazian/u…
christophe-papazian Jul 17, 2024
23f4661
replace commands by a uniq rc state
christophe-papazian Jul 17, 2024
a9ab806
update name
christophe-papazian Jul 17, 2024
0da9edf
Merge remote-tracking branch 'origin/main' into christophe-papazian/u…
christophe-papazian Jul 17, 2024
a9315ec
keep 3.9 syntax compatibility
christophe-papazian Jul 17, 2024
e1c6252
update documentation
christophe-papazian Jul 17, 2024
4d96565
Merge remote-tracking branch 'origin/main' into christophe-papazian/u…
christophe-papazian Jul 17, 2024
7b4a999
fix doc
christophe-papazian Jul 18, 2024
f25bd5f
Merge remote-tracking branch 'origin/main' into christophe-papazian/u…
christophe-papazian Jul 18, 2024
76fba8d
Merge branch 'main' into christophe-papazian/upgrade_rc_api
cbeauchesne Jul 18, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 10 additions & 12 deletions docs/edit/remote-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ from utils import remote_config


# will return the command associated to the current scenario
command = remote_config.RemoteConfigCommand()
rc_state = remote_config.rc_state

config = {
"rules_data": [
Expand All @@ -21,27 +21,25 @@ config = {
]
}

command.add_client_config(f"datadog/2/ASM_DATA-base/ASM_DATA-base/config", config)
# send the command and wait for the result to be validated by the tracer
command.send()
rc_state.set_config(f"datadog/2/ASM_DATA-base/ASM_DATA-base/config", config)
# send the state to the tracer and wait for the result to be validated
command.apply()
```

### API

#### class `remote_config.RemoteConfigCommand`
#### object `remote_config.rc_state`

This class will be serialized as a valid `ClientGetConfigsResponse`.

* constructor `__init__(self, expires=None)`
* `expires` [optional]: expiration date of the config (default `3000-01-01T00:00:00Z`)
* `add_client_config(self, path, config) -> ClientConfig`
* `set_config(self, path, config) -> ClientConfig`
* `path`: configuration path
* `config`: config object
* `del_client_config(self, path) -> ClientConfig`
* `del_config(self, path) -> ClientConfig`
* `path`: configuration path
* `reset(self) -> ClientConfig`
* `send()`: send the command using the `send_command` function (see below)
* `apply()`: send the state using the `send_command` function (see below)

Remember that the state is shared among all tests of a scenario.
You may need to reset it and apply at the start of each setup.

## Sending command

Expand Down
13 changes: 7 additions & 6 deletions tests/appsec/api_security/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ class BaseAppsecApiSecurityRcTest:

def setup_scenario(self):
if BaseAppsecApiSecurityRcTest.states is None:
command = remote_config.RemoteConfigCommand()
command.add_client_config(
rc_state = remote_config.rc_state
rc_state.set_config(
"datadog/2/ASM/ASM-base/config",
{
"processor_override": [
Expand Down Expand Up @@ -36,7 +36,7 @@ def setup_scenario(self):
],
},
)
command.add_client_config(
rc_state.set_config(
"datadog/2/ASM_DD/ASM_DD-base/config",
{
"version": "2.2",
Expand Down Expand Up @@ -109,7 +109,8 @@ def setup_scenario(self):
"value": {
"operator": "match_regex",
"parameters": {
"regex": "\\b[\\w!#$%&'*+/=?`{|}~^-]+(?:\\.[\\w!#$%&'*+/=?`{|}~^-]+)*(%40|@)(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,6}\\b",
"regex": "\\b[\\w!#$%&'*+/=?`{|}~^-]+(?:\\.[\\w!#$%&'*+/=?`{|}~^-]+)*"
"(%40|@)(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,6}\\b",
"options": {"case_sensitive": False, "min_length": 5},
},
},
Expand All @@ -118,9 +119,9 @@ def setup_scenario(self):
],
},
)
command.add_client_config(
rc_state.set_config(
"datadog/2/ASM_FEATURES/ASM_FEATURES-base/config",
{"asm": {"enabled": True}, "api_security": {"request_sample_rate": 1.0}},
)

BaseAppsecApiSecurityRcTest.states = command.send()
BaseAppsecApiSecurityRcTest.states = rc_state.apply()
19 changes: 11 additions & 8 deletions tests/appsec/test_request_blocking.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
# This product includes software developed at Datadog (https://www.datadoghq.com/).
# Copyright 2021 Datadog, Inc.

from utils import weblog, interfaces, scenarios, features, remote_config
from utils import features
from utils import interfaces
from utils import remote_config
from utils import scenarios
from utils import weblog


@scenarios.appsec_request_blocking
Expand All @@ -11,26 +15,25 @@ class Test_AppSecRequestBlocking:
"""A library should block requests when a rule is set to blocking mode."""

def setup_request_blocking(self):
command = remote_config.RemoteConfigCommand()
command.add_client_config(
rc_state = remote_config.rc_state
rc_state.set_config(
"datadog/2/ASM/ASM-base/config",
{"rules_override": [{"on_match": ["block"], "rules_target": [{"tags": {"confidence": "1"}}]}]},
)
command.add_client_config(
rc_state.set_config(
"datadog/2/ASM/ASM-second/config",
{"rules_override": [{"rules_target": [{"rule_id": "crs-913-110"}], "on_match": []}]},
)
self.first_states = command.send()

command.add_client_config("datadog/2/ASM/ASM-base/config", None)
self.second_states = command.send()
self.config_state = rc_state.apply()

self.blocked_requests1 = weblog.get(headers={"user-agent": "Arachni/v1"})
self.blocked_requests2 = weblog.get(params={"random-key": "/netsparker-"})

def test_request_blocking(self):
"""test requests are blocked by rules in blocking mode"""

assert self.config_state[remote_config.RC_STATE] == remote_config.ApplyState.ACKNOWLEDGED

assert self.blocked_requests1.status_code == 403
interfaces.library.assert_waf_attack(self.blocked_requests1, rule="ua0-600-12x")

Expand Down
23 changes: 10 additions & 13 deletions tests/appsec/test_runtime_activation.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,13 @@
CONFIG_EMPTY = None # Empty config to reset the state at test setup
CONFIG_ENABLED = {"asm": {"enabled": True}}

COMMAND = rc.RemoteConfigCommand()


def _send_config(config):
if config is not None:
COMMAND.add_client_config("datadog/2/ASM_FEATURES/asm_features_activation/config", config)
rc.rc_state.set_config("datadog/2/ASM_FEATURES/asm_features_activation/config", config)
else:
COMMAND.del_client_config("datadog/2/ASM_FEATURES/asm_features_activation/config")
return COMMAND.send()
rc.rc_state.del_config("datadog/2/ASM_FEATURES/asm_features_activation/config")
return rc.rc_state.apply()[rc.RC_STATE]


@scenarios.appsec_runtime_activation
Expand All @@ -39,14 +37,12 @@ def setup_asm_features(self):
_send_config(CONFIG_EMPTY)
self.response_with_deactivated_waf = weblog.get("/waf/", headers={"User-Agent": "Arachni/v1"})
self.config_state = _send_config(CONFIG_ENABLED)
self.last_version = COMMAND.version
self.last_version = rc.rc_state.version
self.response_with_activated_waf = weblog.get("/waf/", headers={"User-Agent": "Arachni/v1"})

def test_asm_features(self):
activation_state = self.config_state["asm_features_activation"]
# ensure last config was applied
assert activation_state["apply_state"] == rc.ApplyState.ACKNOWLEDGED, self.config_state
assert self.config_state[rc.RC_STATE] == rc.ApplyState.ACKNOWLEDGED
assert self.config_state == rc.ApplyState.ACKNOWLEDGED
interfaces.library.assert_no_appsec_event(self.response_with_deactivated_waf)
interfaces.library.assert_waf_attack(self.response_with_activated_waf)

Expand All @@ -59,20 +55,21 @@ class Test_RuntimeDeactivation:
def setup_asm_features(self):
self.response_with_activated_waf = []
self.response_with_deactivated_waf = []
self.config_states = []
# deactivate and activate ASM 4 times
for _ in range(4):
_send_config(CONFIG_EMPTY)
self.config_states.append(_send_config(CONFIG_EMPTY))
self.response_with_deactivated_waf.append(weblog.get("/waf/", headers={"User-Agent": "Arachni/v1"}))

_send_config(CONFIG_ENABLED)
self.config_states.append(_send_config(CONFIG_ENABLED))
self.response_with_activated_waf.append(weblog.get("/waf/", headers={"User-Agent": "Arachni/v1"}))

self.config_state = _send_config(CONFIG_EMPTY)
self.config_states.append(_send_config(CONFIG_EMPTY))
self.response_with_deactivated_waf.append(weblog.get("/waf/", headers={"User-Agent": "Arachni/v1"}))

def test_asm_features(self):
# ensure last empty config was applied
assert self.config_state[rc.RC_STATE] == rc.ApplyState.ACKNOWLEDGED
assert all(s == rc.ApplyState.ACKNOWLEDGED for s in self.config_states)
for response in self.response_with_deactivated_waf:
interfaces.library.assert_no_appsec_event(response)
for response in self.response_with_activated_waf:
Expand Down
12 changes: 7 additions & 5 deletions tests/appsec/utils.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
from utils import remote_config, interfaces
from utils import interfaces
from utils import remote_config
from utils.dd_constants import RemoteConfigApplyState


class BaseFullDenyListTest:
states = None

def setup_scenario(self):
# Generate the list of 100 * 125 = 12500 blocked ips that are found in the rc_mocked_responses_asm_data_full_denylist.json
# Generate the list of 100 * 125 = 12500 blocked ips that are found in the
# file rc_mocked_responses_asm_data_full_denylist.json
# to edit or generate a new rc mocked response, use the DataDog/rc-tracer-client-test-generator repository
BLOCKED_IPS = [f"12.8.{a}.{b}" for a in range(100) for b in range(125)]

Expand All @@ -26,10 +28,10 @@ def setup_scenario(self):
]
}

command = remote_config.RemoteConfigCommand()
command.add_client_config("datadog/2/ASM_DATA/ASM_DATA-base/config", config)
rc_state = remote_config.rc_state
rc_state.set_config("datadog/2/ASM_DATA/ASM_DATA-base/config", config)

BaseFullDenyListTest.states = command.send()
BaseFullDenyListTest.states = rc_state.apply()

self.states = BaseFullDenyListTest.states
self.blocked_ips = [BLOCKED_IPS[0], BLOCKED_IPS[2500], BLOCKED_IPS[-1]]
Expand Down
45 changes: 23 additions & 22 deletions utils/_remote_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,7 @@ def __repr__(self) -> str:
return f"""({self.path!r}, {self.raw_deserialized!r}, {self.config_file_version})"""


class RemoteConfigCommand:
class _RemoteConfigState:
"""
https://docs.google.com/document/d/1u_G7TOr8wJX0dOM_zUDKuRJgxoJU_hVTd5SeaMucQUs/edit#heading=h.octuyiil30ph
https://github.com/DataDog/datadog-agent/blob/main/pkg/proto/datadog/remoteconfig/remoteconfig.proto#L180
Expand All @@ -269,22 +269,20 @@ class RemoteConfigCommand:
"e1e81e779c5b536304fe568173c9c0e9125b17c84ce8a58a907bb2f27e7d890b",
}
]
_store: dict[str, "RemoteConfigCommand"] = {}

def __new__(cls, expires=None) -> "RemoteConfigCommand":
scenario = context.scenario.name
if scenario in cls._store:
return cls._store[scenario]
obj = super().__new__(cls)
cls._store[scenario] = obj
obj.targets = {}
obj.version = 0
obj.expires = expires or RemoteConfigCommand.expires
obj.opaque_backend_state = base64.b64encode(obj.backend_state.encode("utf-8")).decode("utf-8")
return obj

def add_client_config(self, path, config, config_file_version=None) -> "RemoteConfigCommand":
"""Add a file"""
_uniq = True

def __init__(self, expires: str | None = None) -> None:
if _RemoteConfigState._uniq:
_RemoteConfigState._uniq = False
else:
raise RuntimeError("Only one instance of _RemoteConfigState can be created")
self.targets: dict[str, ClientConfig] = {}
self.version: int = 0
self.expires: str = expires or _RemoteConfigState.expires
self.opaque_backend_state = base64.b64encode(self.backend_state.encode("utf-8")).decode("utf-8")

def set_config(self, path, config, config_file_version=None) -> "_RemoteConfigState":
"""Set a file in current state."""
client_config = ClientConfig(
path=path,
config=config,
Expand All @@ -293,14 +291,14 @@ def add_client_config(self, path, config, config_file_version=None) -> "RemoteCo
self.targets[path] = client_config
return self

def del_client_config(self, path) -> "RemoteConfigCommand":
"""Remove a file"""
def del_config(self, path) -> "_RemoteConfigState":
"""Remove a file in current state."""
if path in self.targets:
del self.targets[path]
return self

def reset(self) -> "RemoteConfigCommand":
"""Remove all files"""
def reset(self) -> "_RemoteConfigState":
"""Remove all files."""
self.targets.clear()
return self

Expand Down Expand Up @@ -335,9 +333,12 @@ def to_payload(self, deserialized=False):

return result

def send(self, *, wait_for_acknowledged_status: bool = True) -> dict[str, dict[str, Any]]:
def apply(self, *, wait_for_acknowledged_status: bool = True) -> dict[str, dict[str, Any]]:
self.version += 1
command = self.to_payload()
return send_command(
command, wait_for_acknowledged_status=wait_for_acknowledged_status, command_version=self.version
)


rc_state = _RemoteConfigState()
Loading