diff --git a/filter-plugin/logstash-filter-documentdb-aws-guardium/CHANGELOG.md b/filter-plugin/logstash-filter-documentdb-aws-guardium/CHANGELOG.md index 72323f335..200dfe25a 100644 --- a/filter-plugin/logstash-filter-documentdb-aws-guardium/CHANGELOG.md +++ b/filter-plugin/logstash-filter-documentdb-aws-guardium/CHANGELOG.md @@ -2,6 +2,9 @@ Notable changes will be documented in this file. +## [0.1.25] +- GRD-122972: Add support for MongoDB shell regex + ## [0.1.24] - GRD-126808: Add a support for MongoStyle audit logs diff --git a/filter-plugin/logstash-filter-documentdb-aws-guardium/DocumentDBOverCloudwatchPackage/logstash-filter-documentdb_guardium_filter.zip b/filter-plugin/logstash-filter-documentdb-aws-guardium/DocumentDBOverCloudwatchPackage/logstash-filter-documentdb_guardium_filter.zip index 0baef5dfe..7db23f3e7 100644 Binary files a/filter-plugin/logstash-filter-documentdb-aws-guardium/DocumentDBOverCloudwatchPackage/logstash-filter-documentdb_guardium_filter.zip and b/filter-plugin/logstash-filter-documentdb-aws-guardium/DocumentDBOverCloudwatchPackage/logstash-filter-documentdb_guardium_filter.zip differ diff --git a/filter-plugin/logstash-filter-documentdb-aws-guardium/README.md b/filter-plugin/logstash-filter-documentdb-aws-guardium/README.md index b14e215be..5a5b94242 100644 --- a/filter-plugin/logstash-filter-documentdb-aws-guardium/README.md +++ b/filter-plugin/logstash-filter-documentdb-aws-guardium/README.md @@ -116,8 +116,9 @@ The Guardium universal connector is the Guardium entry point for native audit/pr ## 6. Limitations - Due to native limitations in Amazon DocumentDB, audit logs may truncate query details (1 KB limit), and profiler logs only capture slow queries (based on a configurable threshold, around 50 ms). As a result, full query visibility cannot be guaranteed. - - https://docs.aws.amazon.com/documentdb/latest/developerguide/event-auditing.html - - https://docs.aws.amazon.com/documentdb/latest/developerguide/profiling.html +- DocumentDB writes regex arguments as `/pattern/flags` literals (MongoDB shell syntax), which are not valid JSON. The plugin automatically quotes these literals before parsing so that queries containing regular expressions are handled correctly. + - https://docs.aws.amazon.com/documentdb/latest/developerguide/event-auditing.html + - https://docs.aws.amazon.com/documentdb/latest/developerguide/profiling.html - DocumentDB Profiler logs capture any database operations that take longer than some period of time(e. g. 100 ms). If the threshold value is not configurable and set value is too high, then profiler logs may not get captured for every database operation. - The Following important fields couldn't be mapped with DocumentDB audit/profiler logs - Source program : Only available in case of "aggregate" query diff --git a/filter-plugin/logstash-filter-documentdb-aws-guardium/VERSION b/filter-plugin/logstash-filter-documentdb-aws-guardium/VERSION index b9fe11d92..20124ad87 100644 --- a/filter-plugin/logstash-filter-documentdb-aws-guardium/VERSION +++ b/filter-plugin/logstash-filter-documentdb-aws-guardium/VERSION @@ -1 +1 @@ -0.1.24 \ No newline at end of file +0.1.25 \ No newline at end of file diff --git a/filter-plugin/logstash-filter-documentdb-aws-guardium/src/main/java/com/ibm/guardium/documentdb/DocumentdbGuardiumFilter.java b/filter-plugin/logstash-filter-documentdb-aws-guardium/src/main/java/com/ibm/guardium/documentdb/DocumentdbGuardiumFilter.java index 53ff63a11..b94390bc8 100644 --- a/filter-plugin/logstash-filter-documentdb-aws-guardium/src/main/java/com/ibm/guardium/documentdb/DocumentdbGuardiumFilter.java +++ b/filter-plugin/logstash-filter-documentdb-aws-guardium/src/main/java/com/ibm/guardium/documentdb/DocumentdbGuardiumFilter.java @@ -147,6 +147,12 @@ private void processAuditEvent(Event e, String messageString, FilterMatchListene } catch (OutOfMemoryError oom) { handleOutOfMemoryError(e, messageString, matchListener); } catch (JsonSyntaxException jse) { + // A bare BSON regex literal (e.g. /^foo/i) makes Gson fail. Sanitize and retry once. + String sanitized = StringUtils.sanitizeMongoBsonLiterals(messageString); + if (!sanitized.equals(messageString)) { + processAuditEvent(e, sanitized, matchListener); + return; + } handleJsonSyntaxError(e, messageString, jse, matchListener, true); } catch (Exception exception) { handleGenericError(e, messageString, exception, matchListener, true); @@ -187,6 +193,12 @@ private void processProfilerEvent( } catch (OutOfMemoryError oom) { handleOutOfMemoryError(e, messageString, matchListener); } catch (JsonSyntaxException jse) { + // A bare BSON regex literal (e.g. /^foo/i) makes Gson fail. Sanitize and retry once. + String sanitized = StringUtils.sanitizeMongoBsonLiterals(messageString); + if (!sanitized.equals(messageString)) { + processProfilerEvent(e, sanitized, matchListener); + return; + } handleJsonSyntaxError(e, messageString, jse, matchListener, false); } catch (Exception exception) { handleGenericError(e, messageString, exception, matchListener, false); diff --git a/filter-plugin/logstash-filter-documentdb-aws-guardium/src/main/java/com/ibm/guardium/documentdb/Parser.java b/filter-plugin/logstash-filter-documentdb-aws-guardium/src/main/java/com/ibm/guardium/documentdb/Parser.java index 62ba899dd..a54ca41d3 100644 --- a/filter-plugin/logstash-filter-documentdb-aws-guardium/src/main/java/com/ibm/guardium/documentdb/Parser.java +++ b/filter-plugin/logstash-filter-documentdb-aws-guardium/src/main/java/com/ibm/guardium/documentdb/Parser.java @@ -9,6 +9,7 @@ import java.util.Iterator; import java.util.Map.Entry; +import org.apache.commons.lang3.StringEscapeUtils; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -242,7 +243,8 @@ public Construct parseAsConstructAuthCheck(final JsonObject data) { final Sentence sentence = parseSentenceAuthCheck(data); final Construct construct = new Construct(); construct.sentences.add(sentence); - String fullSql = "\"atype\": "+data.get(Constants.FIELD_ATYPE).toString()+","+data.get(Constants.FIELD_PARAM); + String fullSql = StringEscapeUtils.unescapeJava( + "\"atype\": " + data.get(Constants.FIELD_ATYPE).toString() + "," + data.get(Constants.FIELD_PARAM)); construct.setFullSql(fullSql); construct.setRedactedSensitiveDataSql(fullSql); @@ -545,11 +547,15 @@ public Construct parseAsConstructDocumentDb(final JsonObject data) { final Construct construct = new Construct(); construct.sentences.add(sentence); if(data.has(Constants.FIELD_PARAM)) { - construct.setFullSql("\"atype\": "+data.get(Constants.FIELD_ATYPE).toString()+","+data.get(Constants.FIELD_PARAM)); - construct.setRedactedSensitiveDataSql("\"atype\": "+data.get(Constants.FIELD_ATYPE).toString()+","+data.get(Constants.FIELD_PARAM)); + String fullSql = StringEscapeUtils.unescapeJava( + "\"atype\": " + data.get(Constants.FIELD_ATYPE).toString() + "," + data.get(Constants.FIELD_PARAM)); + construct.setFullSql(fullSql); + construct.setRedactedSensitiveDataSql(fullSql); }else if(data.has(Constants.FIELD_COMMAND)) { - construct.setFullSql(data.get(Constants.FIELD_COMMAND).toString()); - construct.setRedactedSensitiveDataSql(data.get(Constants.FIELD_COMMAND).toString()); + String fullSql = StringEscapeUtils.unescapeJava( + data.get(Constants.FIELD_COMMAND).toString()); + construct.setFullSql(fullSql); + construct.setRedactedSensitiveDataSql(fullSql); } return construct; } catch (final Exception e) { diff --git a/filter-plugin/logstash-filter-documentdb-aws-guardium/src/main/java/com/ibm/guardium/documentdb/StringUtils.java b/filter-plugin/logstash-filter-documentdb-aws-guardium/src/main/java/com/ibm/guardium/documentdb/StringUtils.java index 1cfdaae8a..eb3316836 100644 --- a/filter-plugin/logstash-filter-documentdb-aws-guardium/src/main/java/com/ibm/guardium/documentdb/StringUtils.java +++ b/filter-plugin/logstash-filter-documentdb-aws-guardium/src/main/java/com/ibm/guardium/documentdb/StringUtils.java @@ -100,4 +100,86 @@ public static boolean containsAnyProfilerKey(String message) { } return false; } + + /** + * Wraps bare BSON regex literals ({@code /pattern/flags}) in double quotes so the result + * is valid JSON. Slashes inside existing quoted strings are never touched. + */ + public static String sanitizeMongoBsonLiterals(String json) { + if (json == null || json.isEmpty() || !json.contains("/")) { + return json; + } + + StringBuilder out = new StringBuilder(json.length() + 16); + boolean inString = false; + boolean escaped = false; + + int i = 0; + while (i < json.length()) { + char c = json.charAt(i); + + if (inString) { + out.append(c); + if (escaped) { escaped = false; } + else if (c == '\\') { escaped = true; } + else if (c == '"') { inString = false; } + i++; + continue; + } + + if (c == '"') { + inString = true; + out.append(c); + i++; + continue; + } + + // Any unquoted '/' is a BSON regex literal — find its closing '/' + if (c == '/') { + int closingSlash = findRegexClosingSlash(json, i + 1); + int newline = json.indexOf('\n', i + 1); + if (closingSlash != -1 && (newline == -1 || closingSlash < newline)) { + // Consume optional flags after the closing slash + int flagsEnd = closingSlash + 1; + while (flagsEnd < json.length() + && Character.isLetterOrDigit(json.charAt(flagsEnd))) { + flagsEnd++; + } + // Wrap in quotes, escaping '\' and '"' inside the pattern + out.append('"'); + for (int j = i; j < flagsEnd; j++) { + char p = json.charAt(j); + if (p == '\\' || p == '"') out.append('\\'); + out.append(p); + } + out.append('"'); + i = flagsEnd; + continue; + } + } + + out.append(c); + i++; + } + + return out.toString(); + } + + /** + * Returns the index of the closing {@code /} of a BSON regex literal, starting at + * {@code from} (one past the opening {@code /}). Backslash-escaped characters are + * skipped so an escaped {@code \/} inside the pattern is not treated as the delimiter. + * Returns {@code -1} if the end of the line is reached before a closing {@code /}. + */ + private static int findRegexClosingSlash(String json, int from) { + int j = from; + while (j < json.length()) { + char c = json.charAt(j); + if (c == '\n') return -1; + if (c == '\\') { j += 2; continue; } + if (c == '/') return j; + j++; + } + return -1; + } } diff --git a/filter-plugin/logstash-filter-documentdb-aws-guardium/src/test/java/com/ibm/guardium/documentdb/BsonRegexParserTest.java b/filter-plugin/logstash-filter-documentdb-aws-guardium/src/test/java/com/ibm/guardium/documentdb/BsonRegexParserTest.java new file mode 100644 index 000000000..bbc0a7d8d --- /dev/null +++ b/filter-plugin/logstash-filter-documentdb-aws-guardium/src/test/java/com/ibm/guardium/documentdb/BsonRegexParserTest.java @@ -0,0 +1,170 @@ +package com.ibm.guardium.documentdb; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Collection; +import java.util.Collections; + +import org.junit.jupiter.api.Test; +import org.logstash.plugins.ContextImpl; + +import com.google.gson.Gson; +import com.ibm.guardium.universalconnector.commons.GuardConstants; +import com.ibm.guardium.universalconnector.commons.structures.Record; + +import co.elastic.logstash.api.Context; +import co.elastic.logstash.api.Event; +import co.elastic.logstash.api.FilterMatchListener; + +/** End-to-end tests for audit logs containing BSON regex literals. All message strings are synthetic. */ +public class BsonRegexParserTest { + + private static final Context context = new ContextImpl(null, null); + private static final Gson gson = new Gson(); + + // ── helpers ─────────────────────────────────────────────────────────────── + + private static final DocumentdbGuardiumFilter filter = + new DocumentdbGuardiumFilter("test-id", null, context); + + private static class CapturingMatchListener implements FilterMatchListener { + int count = 0; + @Override public void filterMatched(Event e) { count++; } + } + + private Record runFilter(String message) { + Event e = new org.logstash.Event(); + e.setField("message", message); + e.setField("serverHostnamePrefix", "test-cluster"); + e.setField("event_id", + "0000000000000000000000000000000000000000000000000000000000000001"); + + CapturingMatchListener listener = new CapturingMatchListener(); + Collection results = filter.filter(Collections.singletonList(e), listener); + + assertEquals(1, results.size(), "filter should return exactly one event"); + assertEquals(1, listener.count, "event should be matched"); + Object raw = e.getField(GuardConstants.GUARDIUM_RECORD_FIELD_NAME); + assertNotNull(raw, "GuardRecord field must be populated"); + return gson.fromJson(raw.toString(), Record.class); + } + + // ── test cases ──────────────────────────────────────────────────────────── + + /** find: bare regex as a field operator value. */ + @Test + public void testFindWithNotRegex() { + String message = + "{\"atype\":\"authCheck\",\"ts\":1700000000000," + + "\"timestamp_utc\":\"2023-11-14 22:13:20.000\"," + + "\"remote_ip\":\"127.0.0.1:11111\"," + + "\"users\":[{\"user\":\"testuser\",\"db\":\"testdb\"}]," + + "\"param\":{\"command\":\"find\",\"ns\":\"testdb.col\"," + + "\"args\":{\"find\":\"col\"," + + "\"filter\":{\"a\":{\"b\":\"v\"}," + + "\"c\":{\"d\":/^abc/}," + + "\"e\":\"x\"}," + + "\"skip\":0,\"startTransaction\":false}," + + "\"result\":0}}"; + + Record record = runFilter(message); + + assertNotNull(record.getData(), "data must not be null"); + assertNotNull(record.getData().getConstruct(), "construct must not be null"); + assertNull(record.getException(), "no exception expected"); + assertEquals("find", record.getData().getConstruct().sentences.get(0).getVerb(), + "sentence verb must equal param.command"); + } + + /** find: array operator and regex as siblings on the same field. */ + @Test + public void testFindWithNinAndNotRegex() { + String message = + "{\"atype\":\"authCheck\",\"ts\":1700000001000," + + "\"timestamp_utc\":\"2023-11-14 22:13:21.000\"," + + "\"remote_ip\":\"127.0.0.1:22222\"," + + "\"users\":[{\"user\":\"testuser\",\"db\":\"testdb\"}]," + + "\"param\":{\"command\":\"find\",\"ns\":\"testdb.col\"," + + "\"args\":{\"find\":\"col\"," + + "\"filter\":{\"a\":{\"b\":\"v\"}," + + "\"c\":{\"d\":[\"p\",\"q\"],\"e\":/^abc/}," + + "\"f\":\"x\"}," + + "\"skip\":0,\"startTransaction\":false}," + + "\"result\":0}}"; + + Record record = runFilter(message); + + assertNotNull(record.getData()); + assertNotNull(record.getData().getConstruct()); + assertNull(record.getException(), "no exception expected"); + } + + /** find: nested array operators and regex combined in one filter. */ + @Test + public void testFindWithAndNinNotRegex() { + String message = + "{\"atype\":\"authCheck\",\"ts\":1700000002000," + + "\"timestamp_utc\":\"2023-11-14 22:13:22.000\"," + + "\"remote_ip\":\"127.0.0.1:33333\"," + + "\"users\":[{\"user\":\"testuser\",\"db\":\"testdb\"}]," + + "\"param\":{\"command\":\"find\",\"ns\":\"testdb.col\"," + + "\"args\":{\"find\":\"col\"," + + "\"filter\":{\"a\":[{\"b\":{\"c\":[\"p\"]}},{\"d\":{\"e\":[\"q\"]}}]," + + "\"f\":{\"g\":[\"r\",\"s\",\"t\"],\"h\":/^abc/}," + + "\"i\":\"x\"}," + + "\"skip\":0,\"startTransaction\":false}," + + "\"result\":0}}"; + + Record record = runFilter(message); + + assertNotNull(record.getData()); + assertNotNull(record.getData().getConstruct()); + assertNull(record.getException(), "no exception expected"); + } + + /** aggregate: regex with backslash escape sequence in a pipeline stage. */ + @Test + public void testAggregateWithBackslashRegex() { + String message = + "{\"atype\":\"authCheck\",\"ts\":1700000003000," + + "\"timestamp_utc\":\"2023-11-14 22:13:23.000\"," + + "\"remote_ip\":\"127.0.0.1:44444\"," + + "\"users\":[{\"user\":\"testuser\",\"db\":\"testdb\"}]," + + "\"param\":{\"command\":\"aggregate\",\"ns\":\"testdb.col\"," + + "\"args\":{\"aggregate\":\"col\",\"allowDiskUse\":false," + + "\"cursor\":{\"batchSize\":256},\"explain\":false," + + "\"pipeline\":[" + + "{\"$match\":{\"a\":/.*\\wsome term.*/i,\"b\":{\"c\":[\"x\",\"y\"]}}}," + + "{\"$sort\":{\"a\":1}},{\"$skip\":0},{\"$limit\":100}]," + + "\"startTransaction\":false},\"result\":0}}"; + + Record record = runFilter(message); + + assertNotNull(record.getData()); + assertNotNull(record.getData().getConstruct()); + assertNull(record.getException(), "no exception expected"); + } + + /** fullSql must preserve a backslash escape sequence, not double-escape it (unescapeJava regression). */ + @Test + public void testFullSqlPreservesBackslashEscape() { + String message = + "{\"atype\":\"authCheck\",\"ts\":1700000004000," + + "\"timestamp_utc\":\"2023-11-14 22:13:24.000\"," + + "\"remote_ip\":\"127.0.0.1:55555\"," + + "\"users\":[{\"user\":\"testuser\",\"db\":\"testdb\"}]," + + "\"param\":{\"command\":\"aggregate\",\"ns\":\"testdb.col\"," + + "\"args\":{\"aggregate\":\"col\",\"allowDiskUse\":false," + + "\"cursor\":{\"batchSize\":256},\"explain\":false," + + "\"pipeline\":[" + + "{\"$match\":{\"a\":/.*\\wsome term.*/i,\"b\":{\"c\":[\"x\"]}}}]," + + "\"startTransaction\":false},\"result\":0}}"; + + Record record = runFilter(message); + + String fullSql = record.getData().getConstruct().getFullSql(); + assertNotNull(fullSql, "fullSql must not be null"); + assertTrue(fullSql.contains("\\w"), "fullSql must contain \\w"); + assertFalse(fullSql.contains("\\\\w"), "fullSql must NOT contain \\\\w"); + } +} diff --git a/filter-plugin/logstash-filter-documentdb-aws-guardium/src/test/java/com/ibm/guardium/documentdb/StringUtilsTest.java b/filter-plugin/logstash-filter-documentdb-aws-guardium/src/test/java/com/ibm/guardium/documentdb/StringUtilsTest.java index 5f7815e34..3a4678d03 100644 --- a/filter-plugin/logstash-filter-documentdb-aws-guardium/src/test/java/com/ibm/guardium/documentdb/StringUtilsTest.java +++ b/filter-plugin/logstash-filter-documentdb-aws-guardium/src/test/java/com/ibm/guardium/documentdb/StringUtilsTest.java @@ -171,6 +171,154 @@ public void testContainsAnyProfilerKey_NullOrEmpty() { public void testContainsAnyProfilerKey_MultipleKeys() { assertTrue(StringUtils.containsAnyProfilerKey("{ \"insert\": \"col\", \"update\": \"col\" }")); } -} + @Test + public void testSanitizeMongoBsonLiterals_NullOrEmpty() { + assertNull(StringUtils.sanitizeMongoBsonLiterals(null)); + assertEquals("", StringUtils.sanitizeMongoBsonLiterals("")); + } + + @Test + public void testSanitizeMongoBsonLiterals_NoSlashFastPath() { + String input = "{\"a\":\"hello\",\"b\":42}"; + assertSame(input, StringUtils.sanitizeMongoBsonLiterals(input)); + } + + @Test + public void testSanitizeMongoBsonLiterals_NoRegexLiteral() { + String input = "{\"a\":1,\"b\":\"hello\"}"; + assertEquals(input, StringUtils.sanitizeMongoBsonLiterals(input)); + } + + // ── basic structural positions ───────────────────────────────────────────── + + @Test + public void testSanitizeMongoBsonLiterals_SimpleRegex() { + String input = "{\"key\":/^abc/}"; + assertEquals("{\"key\":\"/^abc/\"}", StringUtils.sanitizeMongoBsonLiterals(input)); + } + + @Test + public void testSanitizeMongoBsonLiterals_RegexFollowedByCommaAndMoreKeys() { + String input = "{\"a\":{\"b\":/^abc/},\"c\":\"val\"}"; + assertEquals("{\"a\":{\"b\":\"/^abc/\"},\"c\":\"val\"}", StringUtils.sanitizeMongoBsonLiterals(input)); + } + + @Test + public void testSanitizeMongoBsonLiterals_RegexWithFlags() { + String input = "{\"key\":/^abc/i}"; + assertEquals("{\"key\":\"/^abc/i\"}", StringUtils.sanitizeMongoBsonLiterals(input)); + } + + @Test + public void testSanitizeMongoBsonLiterals_NinArrayThenNotRegex() { + // Array closes before the regex — regex must still be quoted + String input = "{\"a\":{\"b\":[\"x\",\"y\"],\"c\":/^abc/},\"d\":\"val\"}"; + com.google.gson.JsonObject obj = com.google.gson.JsonParser.parseString( + StringUtils.sanitizeMongoBsonLiterals(input)).getAsJsonObject(); + assertEquals("/^abc/", obj.getAsJsonObject("a").get("c").getAsString()); + assertEquals("x", obj.getAsJsonObject("a").getAsJsonArray("b").get(0).getAsString()); + } + + @Test + public void testSanitizeMongoBsonLiterals_MultipleOperatorsThenNotRegex() { + // Multiple sibling keys before the regex — all must survive unchanged + String input = "{\"a\":{\"b\":[\"x\",\"y\"],\"c\":\"z\",\"d\":/^abc/}}"; + com.google.gson.JsonObject obj = com.google.gson.JsonParser.parseString( + StringUtils.sanitizeMongoBsonLiterals(input)).getAsJsonObject(); + com.google.gson.JsonObject a = obj.getAsJsonObject("a"); + assertEquals("/^abc/", a.get("d").getAsString()); + assertEquals("z", a.get("c").getAsString()); + assertEquals("x", a.getAsJsonArray("b").get(0).getAsString()); + } + + // ── regex with backslash escape sequences ───────────────────────────────── + + @Test + public void testSanitizeMongoBsonLiterals_PipelineMatchWithBackslashRegex() { + String input = "{\"a\":[{\"b\":{\"c\":/.*\\wsome term.*/i}}]}"; + com.google.gson.JsonObject obj = com.google.gson.JsonParser.parseString( + StringUtils.sanitizeMongoBsonLiterals(input)).getAsJsonObject(); + String val = obj.getAsJsonArray("a").get(0).getAsJsonObject() + .getAsJsonObject("b").get("c").getAsString(); + assertTrue(val.contains("some term")); + } + + @Test + public void testSanitizeMongoBsonLiterals_PipelineMatchMultipleRegexFields() { + // Two regex values as siblings — both must be quoted + String input = "{\"a\":[{\"b\":{" + + "\"c\":/.*alpha.*/i," + + "\"d\":/.*beta.*/i," + + "\"e\":{\"f\":[\"x\",\"y\"]}}}]}"; + com.google.gson.JsonObject obj = com.google.gson.JsonParser.parseString( + StringUtils.sanitizeMongoBsonLiterals(input)).getAsJsonObject(); + com.google.gson.JsonObject b = obj.getAsJsonArray("a").get(0) + .getAsJsonObject().getAsJsonObject("b"); + assertTrue(b.get("c").getAsString().contains("alpha")); + assertTrue(b.get("d").getAsString().contains("beta")); + } + + // ── strings containing slashes must not be touched ───────────────────────── + + @Test + public void testSanitizeMongoBsonLiterals_Base64SlashInsideStringNotTouched() { + String input = "{\"key\":\"abc/def==\"}"; + assertEquals(input, StringUtils.sanitizeMongoBsonLiterals(input)); + } + + @Test + public void testSanitizeMongoBsonLiterals_StringValueStartingWithSlashNotTouched() { + String input = "{\"key\":\"/a/b/c\"}"; + assertEquals(input, StringUtils.sanitizeMongoBsonLiterals(input)); + } + + @Test + public void testSanitizeMongoBsonLiterals_Base64SlashAndRegexInSameDocument() { + // Slash inside a quoted string must not be touched; unquoted regex must be quoted + String input = "{\"a\":{\"key\":\"abc/def==\"},\"b\":/^abc/}"; + com.google.gson.JsonObject obj = com.google.gson.JsonParser.parseString( + StringUtils.sanitizeMongoBsonLiterals(input)).getAsJsonObject(); + assertEquals("/^abc/", obj.get("b").getAsString()); + assertEquals("abc/def==", obj.getAsJsonObject("a").get("key").getAsString()); + } -// Made with Bob + // ── patterns with backslash sequences and escaped slashes ───────────────── + + @Test + public void testSanitizeMongoBsonLiterals_RegexWithBackslashSequences() { + String input = "{\"key\":/.*\\\\wsome value.*/i}"; + com.google.gson.JsonObject obj = com.google.gson.JsonParser.parseString( + StringUtils.sanitizeMongoBsonLiterals(input)).getAsJsonObject(); + assertTrue(obj.get("key").getAsString().contains("some value")); + } + + @Test + public void testSanitizeMongoBsonLiterals_RegexWithEscapedSlash() { + String input = "{\"key\":/^a?:\\/\\//i}"; + com.google.gson.JsonObject obj = com.google.gson.JsonParser.parseString( + StringUtils.sanitizeMongoBsonLiterals(input)).getAsJsonObject(); + assertTrue(obj.get("key").getAsString().startsWith("/^a")); + } + + // ── nested document with regex ───────────────────────────────────────────── + + @Test + public void testSanitizeMongoBsonLiterals_RegexInsideAggregatePipeline() { + String input = "{\"a\":{\"b\":{\"c\":" + + "[{\"d\":{\"key\":/.*\\\\wtest value.*/i," + + "\"e\":{\"f\":[\"x\",\"y\"]}}}]," + + "\"g\":0}}}"; + com.google.gson.JsonObject obj = com.google.gson.JsonParser.parseString( + StringUtils.sanitizeMongoBsonLiterals(input)).getAsJsonObject(); + String val = obj.getAsJsonObject("a").getAsJsonObject("b") + .getAsJsonArray("c").get(0).getAsJsonObject() + .getAsJsonObject("d").get("key").getAsString(); + assertTrue(val.contains("test value")); + } + + @Test + public void testSanitizeMongoBsonLiterals_NoSlashInInput() { + String input = "{\"a\":{\"b\":{\"c\":true},\"d\":0}}"; + assertSame(input, StringUtils.sanitizeMongoBsonLiterals(input)); + } +}