diff --git a/docs/edit/remote-config.md b/docs/edit/remote-config.md index 786e67bd5f..fa742f5272 100644 --- a/docs/edit/remote-config.md +++ b/docs/edit/remote-config.md @@ -1,6 +1,6 @@ The RC API is the official way to interact with remote config. It allows to build and send RC payloads to the library durint setup phase, and send request before/after each state change. -## Building RC payload +## Setting RC configuration files ### Example @@ -8,7 +8,8 @@ The RC API is the official way to interact with remote config. It allows to buil from utils import remote_config -command = remote_config.RemoteConfigCommand(version=1) +# will return the global rc state +rc_state = remote_config.rc_state config = { "rules_data": [ @@ -20,35 +21,47 @@ config = { ] } -command.add_client_config(f"datadog/2/ASM_DATA-base/ASM_DATA-base/config", config) +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 +rc_state.apply() ``` ### API -#### class `remote_config.RemoteConfigCommand` +#### object `remote_config.rc_state` -This class will be serialized as a valid `ClientGetConfigsResponse`. -* constructor `__init__(self, version: int, client_configs=(), expires=None)` - * `version: int`: `version` property of `signed` object - * `client_configs`[optional]: list of configuration path / config object. - * `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) -> rc_state` * `path`: configuration path * `config`: config object -* `send()`: send the command using the `send_command` function (see below) + *add one configuration in the state* +* `del_config(self, path) -> rc_state` + * `path`: configuration path + + *delete one configuration in the state* +* `reset(self) -> rc_state` + + *delete all configurations in the state* +* `apply() -> tracer_state` + + *send the state using the `send_state` function (see below).* + + *return value can be used to check that the state was correctly applied to the tracer.* + +Remember that the state is shared among all tests of a scenario. +You need to reset it and apply at the start of each setup. -## Sending command +## Sending states ### Example Here is an example a scenario activating/deactivating ASM: 1. the library starts in an initial state where ASM is disabled. This state is validated with an assertion on a request containing an attack : the request should not been caught by ASM -2. Then a RC command is sent to activate ASM +2. Then the RC state is sent to activate ASM 3. another request containing an attack is sent, this one must be reported by ASM -4. A second command is sent to deactivate ASM +4. The state is modified and sent to deactivate ASM 5. a thirst request containing an attack is sent, this last one should not be seen @@ -57,6 +70,7 @@ Here is the test code performing that test. Please note variables `activate_ASM_ ```python from utils import weblog, interfaces, scenarios, remote_config +rc_state = remote_config.rc_state @scenarios.asm_deactivated # in this scenario, ASM is deactivated class Test_RemoteConfigSequence: @@ -67,17 +81,19 @@ class Test_RemoteConfigSequence: self.first_request = weblog.get("/waf/", headers={"User-Agent": "Arachni/v1"}) # this function will send a RC payload to the library, and wait for a confirmation from the library - self.config_states_activation = activate_ASM_command.send() + self.config_states_activation = rc_state.set_config(path, asm_enabled).apply() self.second_request = weblog.get("/waf/", headers={"User-Agent": "Arachni/v1"}) - # now deactivate the WAF, and check that it does not catch anything - self.config_states_deactivation = deactivate_ASM_command.send() + # now deactivate the WAF by deleting the RC file, and check that it does not catch anything + self.config_states_deactivation = rc_state.del_config(path).apply() self.third_request = weblog.get("/waf/", headers={"User-Agent": "Arachni/v1"}) def test_asm_switch_on_switch_off(): # first check that both config state are ok, otherwise, next assertions will fail with cryptic messages + assert self.config_states_activation[remote_config.RC_STATE] == remote_config.ApplyState.ACKNOWLEDGED + assert self.config_states_deactivation[remote_config.RC_STATE] == remote_config.ApplyState.ACKNOWLEDGED + # for non empty config, you can also check for details of files assert self.config_states_activation["asm_features_activation"]["apply_state"] == remote_config.ApplyState.ACKNOWLEDGED, self.config_states_activation - assert self.config_states_deactivation["asm_features_activation"]["apply_state"] == remote_config.ApplyState.ACKNOWLEDGED, self.config_states_deactivation interfaces.library.assert_no_appsec_event(self.first_request) interfaces.library.assert_waf_attack(self.second_request) @@ -88,7 +104,7 @@ To use this feature, you must use an `EndToEndScenario` with `rc_api_enabled=Tru ### API -#### `send_command(raw_payload, *, wait_for_acknowledged_status: bool = True) -> dict[str, dict[str, Any]]` +#### `send_state(raw_payload, *, wait_for_acknowledged_status: bool = True) -> dict[str, dict[str, Any]]` Sends a remote config payload to the library and waits for the config to be applied. Then returns a dictionary with the state of each requested file as returned by the library. diff --git a/manifests/dotnet.yml b/manifests/dotnet.yml index 95131ea1f7..0a5e5b88a7 100644 --- a/manifests/dotnet.yml +++ b/manifests/dotnet.yml @@ -215,6 +215,7 @@ tests/: Test_AppSecRequestBlocking: v2.25.0 test_runtime_activation.py: Test_RuntimeActivation: v2.16.0 + Test_RuntimeDeactivation: v2.16.0 test_shell_execution.py: Test_ShellExecution: missing_feature test_traces.py: diff --git a/manifests/golang.yml b/manifests/golang.yml index ce6a529827..ba18f25e14 100644 --- a/manifests/golang.yml +++ b/manifests/golang.yml @@ -317,6 +317,7 @@ tests/: Test_AppSecRequestBlocking: v1.50.0-rc.1 test_runtime_activation.py: Test_RuntimeActivation: missing_feature + Test_RuntimeDeactivation: missing_feature test_shell_execution.py: Test_ShellExecution: missing_feature test_traces.py: diff --git a/manifests/java.yml b/manifests/java.yml index 6173de919d..745ebcabbb 100644 --- a/manifests/java.yml +++ b/manifests/java.yml @@ -858,6 +858,11 @@ tests/: akka-http: v1.22.0 play: v1.22.0 spring-boot-3-native: missing_feature (GraalVM. Tracing support only) + Test_RuntimeDeactivation: + '*': v0.115.0 + akka-http: v1.22.0 + play: v1.22.0 + spring-boot-3-native: missing_feature (GraalVM. Tracing support only) test_shell_execution.py: Test_ShellExecution: '*': v1.2.0 diff --git a/manifests/nodejs.yml b/manifests/nodejs.yml index 7844ad305d..1cf47c7156 100644 --- a/manifests/nodejs.yml +++ b/manifests/nodejs.yml @@ -347,6 +347,7 @@ tests/: nextjs: missing_feature (can not block by query param in nextjs yet) test_runtime_activation.py: Test_RuntimeActivation: *ref_3_9_0 + Test_RuntimeDeactivation: *ref_3_9_0 test_shell_execution.py: Test_ShellExecution: *ref_5_3_0 test_traces.py: diff --git a/manifests/php.yml b/manifests/php.yml index 8838e604b6..620a669f91 100644 --- a/manifests/php.yml +++ b/manifests/php.yml @@ -179,6 +179,11 @@ tests/: test_reports.py: Test_ExtraTagsFromRule: v0.88.0 Test_Info: v0.68.3 # probably 0.68.2, but was flaky + test_request_blocking.py: + Test_AppSecRequestBlocking: missing_feature # missing version + test_runtime_activation.py: + Test_RuntimeActivation: missing_feature # missing version + Test_RuntimeDeactivation: missing_feature # missing version test_shell_execution.py: Test_ShellExecution: v0.95.0 test_traces.py: diff --git a/manifests/python.yml b/manifests/python.yml index 84d2de20dc..7ce131fcbc 100644 --- a/manifests/python.yml +++ b/manifests/python.yml @@ -449,6 +449,7 @@ tests/: fastapi: v2.4.0.dev1 test_runtime_activation.py: Test_RuntimeActivation: v2.0.0 + Test_RuntimeDeactivation: v2.0.0 test_shell_execution.py: Test_ShellExecution: missing_feature test_traces.py: diff --git a/manifests/ruby.yml b/manifests/ruby.yml index 47382c5dbf..f2ad34784b 100644 --- a/manifests/ruby.yml +++ b/manifests/ruby.yml @@ -220,6 +220,7 @@ tests/: Test_AppSecRequestBlocking: v1.11.1 test_runtime_activation.py: Test_RuntimeActivation: missing_feature + Test_RuntimeDeactivation: missing_feature test_shell_execution.py: Test_ShellExecution: missing_feature test_traces.py: diff --git a/tests/appsec/api_security/utils.py b/tests/appsec/api_security/utils.py index b43c336872..fdc7db2720 100644 --- a/tests/appsec/api_security/utils.py +++ b/tests/appsec/api_security/utils.py @@ -6,8 +6,8 @@ class BaseAppsecApiSecurityRcTest: def setup_scenario(self): if BaseAppsecApiSecurityRcTest.states is None: - command = remote_config.RemoteConfigCommand(version=2) - command.add_client_config( + rc_state = remote_config.rc_state + rc_state.set_config( "datadog/2/ASM/ASM-base/config", { "processor_override": [ @@ -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", @@ -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}, }, }, @@ -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() diff --git a/tests/appsec/test_automated_login_events.py b/tests/appsec/test_automated_login_events.py index 119e697394..979e1ef0af 100644 --- a/tests/appsec/test_automated_login_events.py +++ b/tests/appsec/test_automated_login_events.py @@ -2,18 +2,16 @@ # This product includes software developed at Datadog (https://www.datadoghq.com/). # Copyright 2022 Datadog, Inc. -from utils import ( - weblog, - interfaces, - context, - missing_feature, - scenarios, - rfc, - bug, - features, - irrelevant, - remote_config as rc, -) +from utils import bug +from utils import context +from utils import features +from utils import interfaces +from utils import irrelevant +from utils import missing_feature +from utils import remote_config as rc +from utils import rfc +from utils import scenarios +from utils import weblog @rfc("https://docs.google.com/document/d/1-trUpphvyZY7k5ldjhW-MgqWl0xOm7AMEQDJEAZ63_Q/edit#heading=h.8d3o7vtyu1y1") @@ -38,12 +36,16 @@ class Test_Login_Events: @property def username_key(self): - """ In Rails the parametesr are group by scope. In the case of the test the scope is user. The syntax to group parameters in a POST request is scope[parameter] """ + """In Rails the parametesr are group by scope. In the case of the test the scope is user. + The syntax to group parameters in a POST request is scope[parameter] + """ return "user[username]" if "rails" in context.weblog_variant else "username" @property def password_key(self): - """ In Rails the parametesr are group by scope. In the case of the test the scope is user. The syntax to group parameters in a POST request is scope[parameter] """ + """In Rails the parametesr are group by scope. In the case of the test the scope is user. + The syntax to group parameters in a POST request is scope[parameter] + """ return "user[password]" if "rails" in context.weblog_variant else "password" USER = "test" @@ -279,12 +281,16 @@ class Test_Login_Events_Extended: @property def username_key(self): - """ In Rails the parametesr are group by scope. In the case of the test the scope is user. The syntax to group parameters in a POST request is scope[parameter] """ + """In Rails the parametesr are group by scope. In the case of the test the scope is user. + The syntax to group parameters in a POST request is scope[parameter] + """ return "user[username]" if "rails" in context.weblog_variant else "username" @property def password_key(self): - """ In Rails the parametesr are group by scope. In the case of the test the scope is user. The syntax to group parameters in a POST request is scope[parameter] """ + """In Rails the parametesr are group by scope. In the case of the test the scope is user. + The syntax to group parameters in a POST request is scope[parameter] + """ return "user[password]" if "rails" in context.weblog_variant else "password" USER = "test" @@ -301,7 +307,8 @@ def password_key(self): "Content-Language": "en-GB", "Content-Length": "0", "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", - # "Content-Encoding": "deflate, gzip", # removed because the request is not using this encoding to make the request and makes the test fail + # removed because the request is not using this encoding to make the request and makes the test fail + # "Content-Encoding": "deflate, gzip", "Host": "127.0.0.1:1234", "User-Agent": "Benign User Agent 1.0", "X-Forwarded-For": "42.42.42.42, 43.43.43.43", @@ -396,9 +403,9 @@ def test_login_wrong_user_failure_local(self): if context.library == "ruby": # In ruby we do not have access to the user object since it fails with invalid username # For that reason we can not extract id, email or username - assert meta.get("appsec.events.users.login.failure.usr.id") == None - assert meta.get("appsec.events.users.login.failure.usr.email") == None - assert meta.get("appsec.events.users.login.failure.usr.username") == None + assert meta.get("appsec.events.users.login.failure.usr.id") is None + assert meta.get("appsec.events.users.login.failure.usr.email") is None + assert meta.get("appsec.events.users.login.failure.usr.username") is None elif context.library == "dotnet": # in dotnet if the user doesn't exist, there is no id (generated upon user creation) assert meta["appsec.events.users.login.failure.username"] == "invalidUser" @@ -426,9 +433,9 @@ def test_login_wrong_user_failure_basic(self): if context.library == "ruby": # In ruby we do not have access to the user object since it fails with invalid username # For that reason we can not extract id, email or username - assert meta.get("appsec.events.users.login.failure.usr.id") == None - assert meta.get("appsec.events.users.login.failure.usr.email") == None - assert meta.get("appsec.events.users.login.failure.usr.username") == None + assert meta.get("appsec.events.users.login.failure.usr.id") is None + assert meta.get("appsec.events.users.login.failure.usr.email") is None + assert meta.get("appsec.events.users.login.failure.usr.username") is None elif context.library == "dotnet": # in dotnet if the user doesn't exist, there is no id (generated upon user creation) assert meta["appsec.events.users.login.failure.username"] == "invalidUser" @@ -626,12 +633,16 @@ class Test_V2_Login_Events: @property def username_key(self): - """ In Rails the parametesr are group by scope. In the case of the test the scope is user. The syntax to group parameters in a POST request is scope[parameter] """ + """In Rails the parametesr are group by scope. In the case of the test the scope is user. + The syntax to group parameters in a POST request is scope[parameter] + """ return "user[username]" if "rails" in context.weblog_variant else "username" @property def password_key(self): - """ In Rails the parametesr are group by scope. In the case of the test the scope is user. The syntax to group parameters in a POST request is scope[parameter] """ + """In Rails the parametesr are group by scope. In the case of the test the scope is user. + The syntax to group parameters in a POST request is scope[parameter] + """ return "user[password]" if "rails" in context.weblog_variant else "password" USER = "test" @@ -881,17 +892,21 @@ def test_login_sdk_failure_basic(self): @features.user_monitoring class Test_V2_Login_Events_Anon: """Test login success/failure use cases - As default mode is identification, this scenario will test anonymization. + As default mode is identification, this scenario will test anonymization. """ @property def username_key(self): - """ In Rails the parametesr are group by scope. In the case of the test the scope is user. The syntax to group parameters in a POST request is scope[parameter] """ + """In Rails the parametesr are group by scope. In the case of the test the scope is user. + The syntax to group parameters in a POST request is scope[parameter] + """ return "user[username]" if "rails" in context.weblog_variant else "username" @property def password_key(self): - """ In Rails the parametesr are group by scope. In the case of the test the scope is user. The syntax to group parameters in a POST request is scope[parameter] """ + """In Rails the parametesr are group by scope. In the case of the test the scope is user. + The syntax to group parameters in a POST request is scope[parameter] + """ return "user[password]" if "rails" in context.weblog_variant else "password" USER = "test" @@ -1178,13 +1193,19 @@ def assert_priority(span, meta): @features.user_monitoring @scenarios.appsec_auto_events_rc class Test_V2_Login_Events_RC: - USER = "test" PASSWORD = "1234" # ["disabled", "identification", "anonymization"] PAYLOADS = [ { - "targets": "eyJzaWduZWQiOnsiX3R5cGUiOiJ0YXJnZXRzIiwiY3VzdG9tIjp7Im9wYXF1ZV9iYWNrZW5kX3N0YXRlIjoiZXlKbWIyOGlPaUFpWW1GeUluMD0ifSwiZXhwaXJlcyI6IjMwMDAtMDEtMDFUMDA6MDA6MDBaIiwic3BlY192ZXJzaW9uIjoiMS4wIiwidGFyZ2V0cyI6eyJkYXRhZG9nLzIvQVNNX0ZFQVRVUkVTL2F1dG8tdXNlci1pbnN0cnVtL2NvbmZpZyI6eyJjdXN0b20iOnsidiI6MX0sImhhc2hlcyI6eyJzaGEyNTYiOiJlZDRiNmZmNWRkMmQ3MWI5NjE0YjcxMzMwMTg4MjU2MmNmNGQ4ODk3YWRlMzIzYTZkMmQ5ZGViZDRhNzNhZDA0In0sImxlbmd0aCI6NTZ9fSwidmVyc2lvbiI6MX0sInNpZ25hdHVyZXMiOlt7ImtleWlkIjoiZWQ3NjcyYzlhMjRhYmRhNzg4NzJlZTMyZWU3MWM3Y2IxZDUyMzVlOGRiNGVjYmYxY2EyOGI5YzUwZWI3NWQ5ZSIsInNpZyI6IjIyZDhlOTE0ZWM1NmE0MmQ4MTE4MmE4Y2RkODQyMTI0OTIyMDhlZDllNjRjZjQ2Mjg1ZTIxY2NjMjdhY2NhZDRlZDc3N2Y5MDkwNGVlYmZiODhiNDQ2ZGUxMGNkMjk1YzNjZDJlNjM1NmY4MjMzNDk5MzM1OTQ4YTRkMDI1ZTBkIn1dfQ==", + "targets": "eyJzaWduZWQiOnsiX3R5cGUiOiJ0YXJnZXRzIiwiY3VzdG9tIjp7Im9wYXF1ZV9iYWNrZW5kX3N0YXRlIjoiZXlKbWIyOGl" + "PaUFpWW1GeUluMD0ifSwiZXhwaXJlcyI6IjMwMDAtMDEtMDFUMDA6MDA6MDBaIiwic3BlY192ZXJzaW9uIjoiMS4wIiwidGFyZ2V0cyI6e" + "yJkYXRhZG9nLzIvQVNNX0ZFQVRVUkVTL2F1dG8tdXNlci1pbnN0cnVtL2NvbmZpZyI6eyJjdXN0b20iOnsidiI6MX0sImhhc2hlcyI6eyJ" + "zaGEyNTYiOiJlZDRiNmZmNWRkMmQ3MWI5NjE0YjcxMzMwMTg4MjU2MmNmNGQ4ODk3YWRlMzIzYTZkMmQ5ZGViZDRhNzNhZDA0In0sImxlb" + "md0aCI6NTZ9fSwidmVyc2lvbiI6MX0sInNpZ25hdHVyZXMiOlt7ImtleWlkIjoiZWQ3NjcyYzlhMjRhYmRhNzg4NzJlZTMyZWU3MWM3Y2I" + "xZDUyMzVlOGRiNGVjYmYxY2EyOGI5YzUwZWI3NWQ5ZSIsInNpZyI6IjIyZDhlOTE0ZWM1NmE0MmQ4MTE4MmE4Y2RkODQyMTI0OTIyMDhlZ" + "DllNjRjZjQ2Mjg1ZTIxY2NjMjdhY2NhZDRlZDc3N2Y5MDkwNGVlYmZiODhiNDQ2ZGUxMGNkMjk1YzNjZDJlNjM1NmY4MjMzNDk5MzM1OTQ" + "4YTRkMDI1ZTBkIn1dfQ==", "target_files": [ { "path": "datadog/2/ASM_FEATURES/auto-user-instrum/config", @@ -1194,7 +1215,14 @@ class Test_V2_Login_Events_RC: "client_configs": ["datadog/2/ASM_FEATURES/auto-user-instrum/config"], }, { - "targets": "eyJzaWduZWQiOnsiX3R5cGUiOiJ0YXJnZXRzIiwiY3VzdG9tIjp7Im9wYXF1ZV9iYWNrZW5kX3N0YXRlIjoiZXlKbWIyOGlPaUFpWW1GeUluMD0ifSwiZXhwaXJlcyI6IjMwMDAtMDEtMDFUMDA6MDA6MDBaIiwic3BlY192ZXJzaW9uIjoiMS4wIiwidGFyZ2V0cyI6eyJkYXRhZG9nLzIvQVNNX0ZFQVRVUkVTL2F1dG8tdXNlci1pbnN0cnVtL2NvbmZpZyI6eyJjdXN0b20iOnsidiI6Mn0sImhhc2hlcyI6eyJzaGEyNTYiOiIyZWY2ZDVjMGZhNTQ4NTY0YTRjNWI3NTBjZmRkMDhkOWE4ODk2MmNhZTZkY2M5NDk0MjM4OWMxZDkwOTNkMTBhIn0sImxlbmd0aCI6NjJ9fSwidmVyc2lvbiI6Mn0sInNpZ25hdHVyZXMiOlt7ImtleWlkIjoiZWQ3NjcyYzlhMjRhYmRhNzg4NzJlZTMyZWU3MWM3Y2IxZDUyMzVlOGRiNGVjYmYxY2EyOGI5YzUwZWI3NWQ5ZSIsInNpZyI6ImYzOTMxZDliODk4NWIzNTgxNjc1NWI4N2RjNmFmM2UxYzMzYWJmMjhjZDhkYzVmYWM2ZmMwMzgyZjNlMjUwOGU4ZmZmNzMxMDI2NWFhNDk3NjU2NjAyZDIxMTlhODFhNTViMjkwM2VkMjJlM2IzMzU0MmNhMWZiYmUxYWRhMjBhIn1dfQ==", + "targets": "eyJzaWduZWQiOnsiX3R5cGUiOiJ0YXJnZXRzIiwiY3VzdG9tIjp7Im9wYXF1ZV9iYWNrZW5kX3N0YXRlIjoiZXlKbWIyOGl" + "PaUFpWW1GeUluMD0ifSwiZXhwaXJlcyI6IjMwMDAtMDEtMDFUMDA6MDA6MDBaIiwic3BlY192ZXJzaW9uIjoiMS4wIiwidGFyZ2V0cyI6e" + "yJkYXRhZG9nLzIvQVNNX0ZFQVRVUkVTL2F1dG8tdXNlci1pbnN0cnVtL2NvbmZpZyI6eyJjdXN0b20iOnsidiI6Mn0sImhhc2hlcyI6eyJ" + "zaGEyNTYiOiIyZWY2ZDVjMGZhNTQ4NTY0YTRjNWI3NTBjZmRkMDhkOWE4ODk2MmNhZTZkY2M5NDk0MjM4OWMxZDkwOTNkMTBhIn0sImxlb" + "md0aCI6NjJ9fSwidmVyc2lvbiI6Mn0sInNpZ25hdHVyZXMiOlt7ImtleWlkIjoiZWQ3NjcyYzlhMjRhYmRhNzg4NzJlZTMyZWU3MWM3Y2I" + "xZDUyMzVlOGRiNGVjYmYxY2EyOGI5YzUwZWI3NWQ5ZSIsInNpZyI6ImYzOTMxZDliODk4NWIzNTgxNjc1NWI4N2RjNmFmM2UxYzMzYWJmM" + "jhjZDhkYzVmYWM2ZmMwMzgyZjNlMjUwOGU4ZmZmNzMxMDI2NWFhNDk3NjU2NjAyZDIxMTlhODFhNTViMjkwM2VkMjJlM2IzMzU0MmNhMWZ" + "iYmUxYWRhMjBhIn1dfQ==", "target_files": [ { "path": "datadog/2/ASM_FEATURES/auto-user-instrum/config", @@ -1204,7 +1232,14 @@ class Test_V2_Login_Events_RC: "client_configs": ["datadog/2/ASM_FEATURES/auto-user-instrum/config"], }, { - "targets": "eyJzaWduZWQiOnsiX3R5cGUiOiJ0YXJnZXRzIiwiY3VzdG9tIjp7Im9wYXF1ZV9iYWNrZW5kX3N0YXRlIjoiZXlKbWIyOGlPaUFpWW1GeUluMD0ifSwiZXhwaXJlcyI6IjMwMDAtMDEtMDFUMDA6MDA6MDBaIiwic3BlY192ZXJzaW9uIjoiMS4wIiwidGFyZ2V0cyI6eyJkYXRhZG9nLzIvQVNNX0ZFQVRVUkVTL2F1dG8tdXNlci1pbnN0cnVtL2NvbmZpZyI6eyJjdXN0b20iOnsidiI6M30sImhhc2hlcyI6eyJzaGEyNTYiOiIwMjRiOGM4MmQxODBkZjc2NzMzNzVjYzYzZDdiYmRjMzRiNWE4YzE3NWQzNzE3ZGQwYjYyMzg2OTRhY2FiNWI3In0sImxlbmd0aCI6NjF9fSwidmVyc2lvbiI6M30sInNpZ25hdHVyZXMiOlt7ImtleWlkIjoiZWQ3NjcyYzlhMjRhYmRhNzg4NzJlZTMyZWU3MWM3Y2IxZDUyMzVlOGRiNGVjYmYxY2EyOGI5YzUwZWI3NWQ5ZSIsInNpZyI6IjZlN2FkNDY1MDBiOGU0MTlkZDEyOTQyMjRiMGMzODM0OTZkZjc5OTJhOTliNDkwYWY0MmU1YjRkOTdjZWYxNTI3ZmRjNTAxMGVmYmI2NmYyY2VjMjgyY2Y4NzU5YmFlZThmOWY0ZjA4OWJjODJjNDk3NDUzYjc3YmM4Y2RiYTBkIn1dfQ==", + "targets": "eyJzaWduZWQiOnsiX3R5cGUiOiJ0YXJnZXRzIiwiY3VzdG9tIjp7Im9wYXF1ZV9iYWNrZW5kX3N0YXRlIjoiZXlKbWIyOGl" + "PaUFpWW1GeUluMD0ifSwiZXhwaXJlcyI6IjMwMDAtMDEtMDFUMDA6MDA6MDBaIiwic3BlY192ZXJzaW9uIjoiMS4wIiwidGFyZ2V0cyI6e" + "yJkYXRhZG9nLzIvQVNNX0ZFQVRVUkVTL2F1dG8tdXNlci1pbnN0cnVtL2NvbmZpZyI6eyJjdXN0b20iOnsidiI6M30sImhhc2hlcyI6eyJ" + "zaGEyNTYiOiIwMjRiOGM4MmQxODBkZjc2NzMzNzVjYzYzZDdiYmRjMzRiNWE4YzE3NWQzNzE3ZGQwYjYyMzg2OTRhY2FiNWI3In0sImxlb" + "md0aCI6NjF9fSwidmVyc2lvbiI6M30sInNpZ25hdHVyZXMiOlt7ImtleWlkIjoiZWQ3NjcyYzlhMjRhYmRhNzg4NzJlZTMyZWU3MWM3Y2I" + "xZDUyMzVlOGRiNGVjYmYxY2EyOGI5YzUwZWI3NWQ5ZSIsInNpZyI6IjZlN2FkNDY1MDBiOGU0MTlkZDEyOTQyMjRiMGMzODM0OTZkZjc5O" + "TJhOTliNDkwYWY0MmU1YjRkOTdjZWYxNTI3ZmRjNTAxMGVmYmI2NmYyY2VjMjgyY2Y4NzU5YmFlZThmOWY0ZjA4OWJjODJjNDk3NDUzYjc" + "3YmM4Y2RiYTBkIn1dfQ==", "target_files": [ { "path": "datadog/2/ASM_FEATURES/auto-user-instrum/config", @@ -1217,16 +1252,20 @@ class Test_V2_Login_Events_RC: @property def username_key(self): - """ In Rails the parametesr are group by scope. In the case of the test the scope is user. The syntax to group parameters in a POST request is scope[parameter] """ + """In Rails the parametesr are group by scope. In the case of the test the scope is user. + The syntax to group parameters in a POST request is scope[parameter] + """ return "user[username]" if "rails" in context.weblog_variant else "username" @property def password_key(self): - """ In Rails the parametesr are group by scope. In the case of the test the scope is user. The syntax to group parameters in a POST request is scope[parameter] """ + """In Rails the parametesr are group by scope. In the case of the test the scope is user. + The syntax to group parameters in a POST request is scope[parameter] + """ return "user[password]" if "rails" in context.weblog_variant else "password" def _send_rc_and_execute_request(self, rc_payload): - config_states = rc.send_command(raw_payload=rc_payload) + config_states = rc.send_state(raw_payload=rc_payload) request = weblog.post( "/login?auth=local", data={self.username_key: self.USER, self.password_key: self.PASSWORD} ) @@ -1235,9 +1274,7 @@ def _send_rc_and_execute_request(self, rc_payload): def _assert_response(self, test, validation): config_states, request = test["config_states"], test["request"] - for config_state in config_states.values(): - assert config_state["apply_state"] == rc.ApplyState.ACKNOWLEDGED, config_state - + assert config_states[rc.RC_STATE] == rc.ApplyState.ACKNOWLEDGED assert request.status_code == 200 spans = [s for _, _, s in interfaces.library.get_spans(request=request)] diff --git a/tests/appsec/test_request_blocking.py b/tests/appsec/test_request_blocking.py index ca759a28d0..49bb4f5311 100644 --- a/tests/appsec/test_request_blocking.py +++ b/tests/appsec/test_request_blocking.py @@ -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 @@ -11,19 +15,16 @@ 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(version=0) - 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-"}) @@ -31,6 +32,8 @@ def setup_request_blocking(self): 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") diff --git a/tests/appsec/test_runtime_activation.py b/tests/appsec/test_runtime_activation.py index 14916159f3..8336595fb4 100644 --- a/tests/appsec/test_runtime_activation.py +++ b/tests/appsec/test_runtime_activation.py @@ -2,12 +2,25 @@ # This product includes software developed at Datadog (https://www.datadoghq.com/). # Copyright 2021 Datadog, Inc. -from utils import weblog, context, interfaces, scenarios, bug, features, remote_config as rc +from utils import bug +from utils import context +from utils import features +from utils import interfaces +from utils import remote_config as rc +from utils import scenarios +from utils import weblog -# dd.rc.targets.key.id=TEST_KEY_ID -# dd.rc.targets.key=1def0961206a759b09ccdf2e622be20edf6e27141070e7b164b7e16e96cf402c -# private key: a78bd01afe0dc0baa6904e1b65448a6bbe160e07f7fc375c3bcb3ec08f008cc5 +CONFIG_EMPTY = None # Empty config to reset the state at test setup +CONFIG_ENABLED = {"asm": {"enabled": True}} + + +def _send_config(config): + if config is not None: + rc.rc_state.set_config("datadog/2/ASM_FEATURES/asm_features_activation/config", config) + else: + 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 @@ -21,18 +34,43 @@ class Test_RuntimeActivation: """A library should block requests after AppSec is activated via remote config.""" def setup_asm_features(self): - command = rc.RemoteConfigCommand(version=1) - - config = {"asm": {"enabled": True}} - - command.add_client_config("datadog/2/ASM_FEATURES/asm_features_activation/config", config) - + _send_config(CONFIG_EMPTY) self.response_with_deactivated_waf = weblog.get("/waf/", headers={"User-Agent": "Arachni/v1"}) - self.config_state = command.send() + self.config_state = _send_config(CONFIG_ENABLED) + 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"] - assert activation_state["apply_state"] == rc.ApplyState.ACKNOWLEDGED, self.config_state + # ensure last config was applied + 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) + + +@scenarios.appsec_runtime_activation +@features.appsec_request_blocking +class Test_RuntimeDeactivation: + """A library should stop blocking after Appsec is deactivated.""" + + 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): + self.config_states.append(_send_config(CONFIG_EMPTY)) + self.response_with_deactivated_waf.append(weblog.get("/waf/", headers={"User-Agent": "Arachni/v1"})) + + self.config_states.append(_send_config(CONFIG_ENABLED)) + self.response_with_activated_waf.append(weblog.get("/waf/", headers={"User-Agent": "Arachni/v1"})) + + 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 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: + interfaces.library.assert_waf_attack(response) diff --git a/tests/appsec/utils.py b/tests/appsec/utils.py index 4e76bc8426..359ac8832f 100644 --- a/tests/appsec/utils.py +++ b/tests/appsec/utils.py @@ -1,13 +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 - TARGETS_VERSION = 42 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)] @@ -27,10 +28,10 @@ def setup_scenario(self): ] } - command = remote_config.RemoteConfigCommand(version=self.TARGETS_VERSION) - 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]] @@ -38,7 +39,7 @@ def setup_scenario(self): def assert_protocol_is_respected(self): interfaces.library.assert_rc_targets_version_states(targets_version=0, config_states=[]) interfaces.library.assert_rc_targets_version_states( - targets_version=self.TARGETS_VERSION, + targets_version=self.states[remote_config.RC_VERSION], config_states=[ { "id": "ASM_DATA-base", diff --git a/utils/_remote_config.py b/utils/_remote_config.py index 21dd5d4889..2d478134a2 100644 --- a/utils/_remote_config.py +++ b/utils/_remote_config.py @@ -4,16 +4,17 @@ import base64 import hashlib -from typing import Any import json import os import re +from typing import Any +from typing import Optional import requests -from utils.interfaces import library from utils._context.core import context from utils.dd_constants import RemoteConfigApplyState as ApplyState +from utils.interfaces import library from utils.tools import logger @@ -32,23 +33,33 @@ def _post(path: str, payload) -> None: requests.post(f"http://{domain}:11111{path}", data=json.dumps(payload), timeout=30) -def send_command(raw_payload, *, wait_for_acknowledged_status: bool = True) -> dict[str, dict[str, Any]]: +RC_VERSION = "_ci_global_version" +RC_STATE = "_ci_state" + + +def send_state( + raw_payload, *, wait_for_acknowledged_status: bool = True, state_version: int = -1 +) -> dict[str, dict[str, Any]]: """ - Sends a remote config payload to the library and waits for the config to be applied. - Then returns a dictionary with the state of each requested file as returned by the library. - - The dictionary keys are the IDs from the files that can be extracted from the path, - e.g: datadog/2/ASM_FEATURES/asm_features_activation/config => asm_features_activation - and the values contain the actual state for each file: - - 1. a config state acknowledging the config - 2. else if not acknowledged, the last config state received - 3. if no config state received, then a hardcoded one with apply_state=UNKNOWN - - Arguments: - wait_for_acknowledge_status - If True, waits for the config to be acknowledged by the library. - Else, only wait for the next request sent to /v0.7/config + Sends a remote config payload to the library and waits for the config to be applied. + Then returns a dictionary with the state of each requested file as returned by the library. + + The dictionary keys are the IDs from the files that can be extracted from the path, + e.g: datadog/2/ASM_FEATURES/asm_features_activation/config => asm_features_activation + and the values contain the actual state for each file: + + 1. a config state acknowledging the config + 2. else if not acknowledged, the last config state received + 3. if no config state received, then a hardcoded one with apply_state=UNKNOWN + + Arguments: + wait_for_acknowledge_status + If True, waits for the config to be acknowledged by the library. + Else, only wait for the next request sent to /v0.7/config + state_version + The version of the global state. + It should be larger than previous versions if you want to apply a new config. + """ assert context.scenario.rc_api_enabled, f"Remote config API is not enabled on {context.scenario}" @@ -57,27 +68,31 @@ def send_command(raw_payload, *, wait_for_acknowledged_status: bool = True) -> d current_states = {} version = None - if len(client_configs) == 0: - if wait_for_acknowledged_status: - raise ValueError("Empty client config list is not supported with wait_for_acknowledged_status=True") - else: - targets = json.loads(base64.b64decode(raw_payload["targets"])) - version = targets["signed"]["version"] - for client_config in client_configs: - _, _, product, config_id, _ = client_config.split("/") - current_states[config_id] = { - "id": config_id, - "product": product, - "apply_state": ApplyState.UNKNOWN, - "apply_error": "", - } + targets = json.loads(base64.b64decode(raw_payload["targets"])) + version = targets["signed"]["version"] + for client_config in client_configs: + _, _, product, config_id, _ = client_config.split("/") + current_states[config_id] = { + "id": config_id, + "product": product, + "apply_state": ApplyState.UNKNOWN, + "apply_error": "", + } + current_states[RC_VERSION] = state_version + current_states[RC_STATE] = ApplyState.UNKNOWN + + state = {} def remote_config_applied(data): + nonlocal state if data["path"] == "/v0.7/config": - if len(client_configs) == 0: # is there a way to know if the "no-config" is acknowledged ? - return True - state = data.get("request", {}).get("content", {}).get("client", {}).get("state", {}) + if len(client_configs) == 0: + found = state["targets_version"] == state_version and state.get("config_states", []) == [] + if found: + current_states[RC_STATE] = ApplyState.ACKNOWLEDGED + return found + if state["targets_version"] == version: config_states = state.get("config_states", []) for state in config_states: @@ -87,10 +102,12 @@ def remote_config_applied(data): config_state.update(state) if wait_for_acknowledged_status: - for state in current_states.values(): - if state["apply_state"] == ApplyState.UNKNOWN: - return False + for key, state in current_states.items(): + if key not in (RC_VERSION, RC_STATE): + if state["apply_state"] == ApplyState.UNKNOWN: + return False + current_states[RC_STATE] = ApplyState.ACKNOWLEDGED return True _post("/unique_command", raw_payload) @@ -100,7 +117,7 @@ def remote_config_applied(data): def send_sequential_commands(commands: list[dict]) -> None: - """ DEPRECATED """ + """DEPRECATED""" _post("/sequential_commands", commands) @@ -142,7 +159,8 @@ def _get_probe_type(probe_id): { # where does this come from ? "keyid": "ed7672c9a24abda78872ee32ee71c7cb1d5235e8db4ecbf1ca28b9c50eb75d9e", - "sig": "e2279a554d52503f5bd68e0a9910c7e90c9bb81744fe9c8824ea3737b279d9e69b3ce5f4b463c402ebe34964fb7a69625eb0e91d3ddbd392cc8b3210373d9b0f", # pylint: disable=line-too-long + "sig": "e2279a554d52503f5bd68e0a9910c7e90c9bb81744fe9c8824ea3737b279d9e6" + "9b3ce5f4b463c402ebe34964fb7a69625eb0e91d3ddbd392cc8b3210373d9b0f", } ], } @@ -190,7 +208,7 @@ def _get_probe_type(probe_id): def send_debugger_command(probes: list, version: int) -> dict: raw_payload = build_debugger_command(probes, version) - return send_command(raw_payload) + return send_state(raw_payload) def _json_to_base64(json_object): @@ -240,7 +258,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 @@ -252,25 +270,42 @@ class RemoteConfigCommand: signatures = [ { "keyid": "ed7672c9a24abda78872ee32ee71c7cb1d5235e8db4ecbf1ca28b9c50eb75d9e", - "sig": "f5f2f27035339ed841447713eb93e5c62c34f4fa709fac0f9edca4ef5dc77340e1e81e779c5b536304fe568173c9c0e9125b17c84ce8a58a907bb2f27e7d890b", # pylint: disable=line-too-long + "sig": "f5f2f27035339ed841447713eb93e5c62c34f4fa709fac0f9edca4ef5dc77340" + "e1e81e779c5b536304fe568173c9c0e9125b17c84ce8a58a907bb2f27e7d890b", } ] + _uniq = True - def __init__(self, version: int, client_configs=(), expires=None) -> None: - self.targets: list[ClientConfig] = [] - self.version = version - self.expires = expires or self.expires - - for args in client_configs: - self.add_client_config(*args) - + def __init__(self, expires: Optional[str] = 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 add_client_config(self, path, config, config_file_version=None) -> ClientConfig: - client_config = ClientConfig(path=path, config=config, config_file_version=config_file_version) - self.targets.append(client_config) - - return client_config + def set_config(self, path, config, config_file_version=None) -> "_RemoteConfigState": + """Set a file in current state.""" + client_config = ClientConfig( + path=path, + config=config, + config_file_version=(self.version + 1) if config_file_version is None else config_file_version, + ) + self.targets[path] = client_config + return self + + 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) -> "_RemoteConfigState": + """Remove all files.""" + self.targets.clear() + return self def serialize_targets(self, deserialized=False): result = { @@ -279,7 +314,7 @@ def serialize_targets(self, deserialized=False): "custom": {"opaque_backend_state": self.opaque_backend_state}, "expires": self.expires, "spec_version": self.spec_version, - "targets": {target.path: target.get_target() for target in self.targets}, + "targets": {path: target.get_target() for path, target in self.targets.items()}, "version": self.version, }, "signatures": self.signatures, @@ -292,17 +327,23 @@ def to_payload(self, deserialized=False): target_files = [ target.get_target_file(deserialized=deserialized) - for target in self.targets + for target in self.targets.values() if target.raw_deserialized is not None ] if len(target_files) > 0: result["target_files"] = target_files if len(self.targets) > 0: - result["client_configs"] = [target.path for target in self.targets] + result["client_configs"] = list(self.targets) 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) + return send_state( + command, wait_for_acknowledged_status=wait_for_acknowledged_status, state_version=self.version + ) + + +rc_state = _RemoteConfigState()