diff --git a/agent/redact.py b/agent/redact.py index b8b9be8912f9..ea70246a9079 100644 --- a/agent/redact.py +++ b/agent/redact.py @@ -171,9 +171,13 @@ r"^(?:os\.(?:getenv|environ)|process\.env|\$ENV\{)" ) # Namespaced (dotted) key: the secret word may sit anywhere in a dotted path. +# NOTE(perf): possessive quantifiers (py3.11+) replace the nested quantifier +# ``(?:[A-Za-z0-9_\-]+\.)+`` (exponential backtracking on long dotted runs). +# The ``*`` runs bordering {_SECRET_CFG_NAMES} must stay backtrackable +# (secret words are matchable by the class, e.g. ``app.api.key=…``). _CFG_DOTTED_RE = re.compile( - rf"((?:[A-Za-z0-9_\-]+\.)+[A-Za-z0-9_.\-]*{_SECRET_CFG_NAMES}[A-Za-z0-9_.\-]*" - rf"|[A-Za-z0-9_.\-]*{_SECRET_CFG_NAMES}[A-Za-z0-9_.\-]*\.[A-Za-z0-9_.\-]+)" + rf"([A-Za-z0-9_\-]++\.[A-Za-z0-9_.\-]*{_SECRET_CFG_NAMES}[A-Za-z0-9_.\-]*+" + rf"|[A-Za-z0-9_.\-]*{_SECRET_CFG_NAMES}[A-Za-z0-9_.\-]*\.[A-Za-z0-9_.\-]++)" rf"={_CFG_VALUE}", re.IGNORECASE, ) @@ -192,8 +196,10 @@ # is masked by _AUTH_HEADER_RE); ``auth_token``/``auth-token`` still match via # the ``token`` keyword. Quoted values defer to _JSON_FIELD_RE via the lookahead. _YAML_CFG_NAMES = r"(?:api[ _.\-]?key|token|secret|passwd|password|credential)" +# NOTE(perf): possessive quantifiers wherever the successor is disjoint; the +# leading ``[A-Za-z0-9_.\-]*`` stays backtrackable (see _CFG_DOTTED_RE note). _YAML_ASSIGN_RE = re.compile( - rf"(^[ \t]*[A-Za-z0-9_.\-]*{_YAML_CFG_NAMES}[A-Za-z0-9_.\-]*)(:[ \t]*)(?!['\"])([^\s&]+)", + rf"(^[ \t]*+[A-Za-z0-9_.\-]*{_YAML_CFG_NAMES}[A-Za-z0-9_.\-]*+)(:[ \t]*+)(?!['\"])([^\s&]++)", re.IGNORECASE | re.MULTILINE, ) diff --git a/contributors/emails/carlotestor@users.noreply.github.com b/contributors/emails/carlotestor@users.noreply.github.com new file mode 100644 index 000000000000..0c29f6411780 --- /dev/null +++ b/contributors/emails/carlotestor@users.noreply.github.com @@ -0,0 +1 @@ +carlotestor diff --git a/tests/agent/test_redact.py b/tests/agent/test_redact.py index 2ea405f051a2..68307c7381d2 100644 --- a/tests/agent/test_redact.py +++ b/tests/agent/test_redact.py @@ -545,6 +545,65 @@ def test_prose_mid_sentence_password_unchanged(self): +class TestConfigKeyRedosResistance: + """The dotted-key patterns must not backtrack exponentially (ReDoS). + + Before the possessive-quantifier rewrite, a non-matching run of ~40 + dotted segments took ~30ms and doubled every ~4 segments; 100 segments + would effectively hang the redactor (it runs on every log line). + """ + + def test_long_dotted_run_completes_fast(self): + import time + + # 100 dotted segments with no '=' — worst case for the old pattern. + text = ".".join(["segment"] * 100) + " end" + t0 = time.perf_counter() + assert redact_sensitive_text(text) == text + assert time.perf_counter() - t0 < 2.0 + + def test_long_dotted_run_with_keyword_completes_fast(self): + """Exercise _CFG_DOTTED_RE directly (bypasses the keyword pre-gate). + + The pre-gate skips the regex when no secret keyword is present, so + test_long_dotted_run_completes_fast only guards the pre-gate. This + test includes a keyword but no '=' so the regex runs and must still + complete quickly thanks to the possessive quantifiers. + """ + import time + + text = ".".join(["segment"] * 100) + ".token end" + t0 = time.perf_counter() + assert redact_sensitive_text(text) == text + assert time.perf_counter() - t0 < 2.0 + + def test_long_dotted_secret_still_redacted(self): + # Possessive quantifiers must not change matching behavior. + text = ".".join(["seg"] * 50) + ".password=Sup3rS3cret!" + result = redact_sensitive_text(text) + assert "Sup3rS3cret!" not in result + assert ".password=" in result + + def test_yaml_assign_redos_resistance(self): + """_YAML_ASSIGN_RE must not backtrack excessively on long inputs.""" + import time + + # 100 lines of a long dotted key with a secret keyword but no + # matching colon-value form — stresses the regex without matching. + line = "a." * 50 + "token not_an_assignment" + text = "\n".join([line] * 100) + t0 = time.perf_counter() + redact_sensitive_text(text) + assert time.perf_counter() - t0 < 2.0 + + def test_yaml_assign_secret_still_redacted(self): + # Possessive quantifiers must not change YAML matching behavior. + text = "spring.datasource.password: hunter2" + result = redact_sensitive_text(text) + assert "hunter2" not in result + assert "password:" in result + + class TestXaiToken: KEY = "xai-ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstu"