Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
104 changes: 103 additions & 1 deletion appsec/helper-rust/src/telemetry/tel_aware_logger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ fn submit_error_to_telemetry(record: &Record) {
tags.add("module", module);
}

let message = format!("{}", record.args());
let message = redact_waf_strings(&format!("{}", record.args())).into_owned();

let location = if let (Some(module), Some(line)) = (record.module_path(), record.line()) {
Cow::Owned(format!("{}:{}", module, line))
Expand Down Expand Up @@ -154,6 +154,53 @@ impl<'kvs> VisitSource<'kvs> for BacktraceExtractor {
}
}

/// Replace every `WafString("…")` span with `WafString("<REDACTED>")`.
///
/// The Debug impl of libddwaf `WafString` escapes `"` as `\"` and `\` as `\\`
/// inside the delimiters, so the closing `")` can only be an unescaped `"`
/// followed by `)`. Returns `Cow::Borrowed` when no replacement is needed.
pub(crate) fn redact_waf_strings(msg: &str) -> Cow<'_, str> {
const OPEN: &str = "WafString(\"";
const REPLACEMENT: &str = "WafString(\"<REDACTED>\")";

if !msg.contains(OPEN) {
return Cow::Borrowed(msg);
}

let mut out = String::with_capacity(msg.len());
let mut rest = msg;

while let Some(open_at) = rest.find(OPEN) {
let content = &rest[open_at + OPEN.len()..];
let Some(end) = find_waf_string_end(content) else {
break;
};
out.push_str(&rest[..open_at]);
out.push_str(REPLACEMENT);
rest = &content[end..];
}

out.push_str(rest);
Cow::Owned(out)
}

/// Given the bytes right after the opening `WafString("`, return the offset
/// just past the closing `")`, treating `\\` and `\"` as escapes inside the
/// quoted content. Returns `None` if the string is unterminated.
fn find_waf_string_end(content: &str) -> Option<usize> {
let bytes = content.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'\\' if i + 1 < bytes.len() => i += 2, // skip escaped char
b'"' if bytes.get(i + 1) == Some(&b')') => return Some(i + 2),
b'"' => return None, // bare `"` not followed by `)` — malformed
_ => i += 1,
}
}
None
}

fn extract_anyhow_backtrace(record: &Record) -> Option<String> {
let mut extractor = BacktraceExtractor { backtrace: None };
let _ = record.key_values().visit(&mut extractor);
Expand Down Expand Up @@ -300,6 +347,61 @@ mod tests {
assert!(extracted.is_none());
}

#[test]
fn test_redact_waf_strings_no_match_is_borrowed() {
let input = "no waf data here, just text";
let out = redact_waf_strings(input);
assert!(matches!(out, Cow::Borrowed(_)));
assert_eq!(out, input);
}

#[test]
fn test_redact_waf_strings_single() {
let input = r#"error: WafString("Mozilla/5.0 secret")"#;
let out = redact_waf_strings(input);
assert_eq!(out, r#"error: WafString("<REDACTED>")"#);
}

#[test]
fn test_redact_waf_strings_escaped_quote_not_treated_as_close() {
let input = r#"WafString("say \"hi\"")"#;
let out = redact_waf_strings(input);
assert_eq!(out, r#"WafString("<REDACTED>")"#);
}

#[test]
fn test_redact_waf_strings_escaped_backslash_before_close() {
let input = r#"WafString("trailing backslash\\")"#;
let out = redact_waf_strings(input);
assert_eq!(out, r#"WafString("<REDACTED>")"#);
}

#[test]
fn test_primary_delegate_receives_unredacted_error_message() {
let logs = Arc::new(Mutex::new(Vec::new()));
let primary = Box::new(TestLogger { logs: logs.clone() });
let composite = TelemetryAwareLogger::new(primary);

let record = log::Record::builder()
.args(format_args!(
r#"error in request loop: unexpected command WafString("ORIGINAL_SECRET")"#
))
.level(Level::Error)
.build();

composite.log(&record);

let captured = logs.lock().unwrap();
assert_eq!(captured.len(), 1);
// Delegate must see the original, unredacted message.
assert!(
captured[0].contains("ORIGINAL_SECRET"),
"delegate did not receive the original message, got: {}",
captured[0]
);
assert!(!captured[0].contains("<REDACTED>"));
}

#[test]
fn test_extract_anyhow_backtrace_with_other_keys() {
use log::kv::{self, ToValue};
Expand Down
21 changes: 15 additions & 6 deletions appsec/src/extension/ddappsec.c
Original file line number Diff line number Diff line change
Expand Up @@ -621,12 +621,16 @@ ZEND_ARG_TYPE_INFO(0, data, IS_ARRAY, 0)
ZEND_END_ARG_INFO()

// clang-format off
static const zend_function_entry testing_functions[] = {
// Available under either DD_APPSEC_TESTING or DD_APPSEC_TESTING_INVALID_COMMAND
static const zend_function_entry testing_request_control_functions[] = {
ZEND_RAW_FENTRY(DD_TESTING_NS "rinit", PHP_FN(datadog_appsec_testing_rinit), void_ret_bool_arginfo, 0, NULL, NULL)
ZEND_RAW_FENTRY(DD_TESTING_NS "rshutdown", PHP_FN(datadog_appsec_testing_rshutdown), void_ret_bool_arginfo, 0, NULL, NULL)
ZEND_RAW_FENTRY(DD_TESTING_NS "request_exec", PHP_FN(datadog_appsec_testing_request_exec), request_exec_arginfo, 0, NULL, NULL)
PHP_FE_END
};
static const zend_function_entry testing_functions[] = {
ZEND_RAW_FENTRY(DD_TESTING_NS "helper_mgr_acquire_conn", PHP_FN(datadog_appsec_testing_helper_mgr_acquire_conn), void_ret_bool_arginfo, 0, NULL, NULL)
ZEND_RAW_FENTRY(DD_TESTING_NS "stop_for_debugger", PHP_FN(datadog_appsec_testing_stop_for_debugger), void_ret_bool_arginfo, 0, NULL, NULL)
ZEND_RAW_FENTRY(DD_TESTING_NS "request_exec", PHP_FN(datadog_appsec_testing_request_exec), request_exec_arginfo, 0, NULL, NULL)
PHP_FE_END
};
static const zend_function_entry testing_invalid_command_functions[] = {
Expand All @@ -637,13 +641,18 @@ static const zend_function_entry testing_invalid_command_functions[] = {

static void _register_testing_objects(void)
{
if (get_global_DD_APPSEC_TESTING_INVALID_COMMAND()) {
bool invalid_command = get_global_DD_APPSEC_TESTING_INVALID_COMMAND();
bool full_testing = get_global_DD_APPSEC_TESTING();

if (invalid_command) {
dd_phpobj_reg_funcs(testing_invalid_command_functions);
}

if (!get_global_DD_APPSEC_TESTING()) {
return;
if (invalid_command || full_testing) {
dd_phpobj_reg_funcs(testing_request_control_functions);
}

dd_phpobj_reg_funcs(testing_functions);
if (full_testing) {
dd_phpobj_reg_funcs(testing_functions);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,16 @@ class AppSecContainer<SELF extends AppSecContainer<SELF>> extends GenericContain
traceFromRequest(req, HttpResponse.BodyHandlers.ofInputStream(), doWithConn, ignoreOtherRequests)
}

<T> Trace traceFromRequest(String path,
HttpResponse.BodyHandler<T> bodyHandler,
@ClosureParams(value = FromString,
options = 'java.net.http.HttpResponse<T>')
Closure<Void> doWithResp = null,
boolean ignoreOtherRequests = false) {
HttpRequest req = buildReq(path).GET().build()
traceFromRequest(req, bodyHandler, doWithResp, ignoreOtherRequests)
}

@CompileStatic(TypeCheckingMode.SKIP)
<T> Trace traceFromRequest(HttpRequest req,
HttpResponse.BodyHandler<T> bodyHandler,
Expand Down
Loading
Loading