diff --git a/README.md b/README.md index 26ff0426..5c5d7b54 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,94 @@ The log module supports contextual logging, allowing you to create loggers with For more details and advanced usage, see the module specification and API documentation. +## Sensitive Data Masking + +The log module provides capabilities to mask sensitive data in log messages to maintain data privacy and security when dealing with personally identifiable information (PII) or other sensitive data. + +> **Note**: By default, sensitive data masking is disabled. Enable it in `Config.toml`: +> +> ```toml +> [ballerina.log] +> enableSensitiveDataMasking = true +> ``` +> +> Or configure it per logger: +> +> ```ballerina +> log:Config secureConfig = { +> enableSensitiveDataMasking: true +> }; +> log:Logger secureLogger = log:fromConfig(secureConfig); +> ``` + +### Sensitive Data Annotation + +Use the `@log:Sensitive` annotation to mark fields in records as sensitive. When such fields are logged, their values will be excluded or masked: + +```ballerina +import ballerina/log; + +type User record { + string id; + @log:Sensitive + string password; + string name; +}; + +public function main() { + User user = {id: "U001", password: "mypassword", name: "John Doe"}; + log:printInfo("user details", user = user); +} +``` + +Output (with masking enabled): + +```log +time=2025-08-20T09:15:30.123+05:30 level=INFO module="" message="user details" user={"id":"U001","name":"John Doe"} +``` + +### Masking Strategies + +Configure masking strategies using the `strategy` field: + +```ballerina +import ballerina/log; + +isolated function maskString(string input) returns string { + if input.length() <= 2 { + return "****"; + } + return input.substring(0, 1) + "****" + input.substring(input.length() - 1); +} + +type User record { + string id; + @log:Sensitive { + strategy: { + replacement: "****" + } + } + string password; + @log:Sensitive { + strategy: { + replacement: maskString + } + } + string ssn; + string name; +}; +``` + +### Masked String Function + +Use `log:toMaskedString()` to get the masked version of a value for custom logging implementations: + +```ballerina +User user = {id: "U001", password: "mypassword", name: "John Doe"}; +string maskedUser = log:toMaskedString(user); +io:println(maskedUser); // {"id":"U001","name":"John Doe"} +``` + ## Build from the source ### Set up the prerequisites diff --git a/ballerina/Ballerina.toml b/ballerina/Ballerina.toml index 731b7cf5..8159e2d3 100644 --- a/ballerina/Ballerina.toml +++ b/ballerina/Ballerina.toml @@ -1,7 +1,7 @@ [package] org = "ballerina" name = "log" -version = "2.13.0" +version = "2.14.0" authors = ["Ballerina"] keywords = ["level", "format"] repository = "https://github.com/ballerina-platform/module-ballerina-log" @@ -15,18 +15,18 @@ graalvmCompatible = true [[platform.java21.dependency]] groupId = "io.ballerina.stdlib" artifactId = "log-native" -version = "2.13.0" -path = "../native/build/libs/log-native-2.13.0.jar" +version = "2.14.0" +path = "../native/build/libs/log-native-2.14.0-SNAPSHOT.jar" [[platform.java21.dependency]] groupId = "io.ballerina.stdlib" artifactId = "log-compiler-plugin" -version = "2.13.0" -path = "../compiler-plugin/build/libs/log-compiler-plugin-2.13.0.jar" +version = "2.14.0" +path = "../compiler-plugin/build/libs/log-compiler-plugin-2.14.0-SNAPSHOT.jar" [[platform.java21.dependency]] groupId = "io.ballerina.stdlib" artifactId = "log-test-utils" -version = "2.13.0" -path = "../test-utils/build/libs/log-test-utils-2.13.0.jar" +version = "2.14.0" +path = "../test-utils/build/libs/log-test-utils-2.14.0-SNAPSHOT.jar" scope = "testOnly" diff --git a/ballerina/CompilerPlugin.toml b/ballerina/CompilerPlugin.toml index 5f2950e8..100bfa67 100644 --- a/ballerina/CompilerPlugin.toml +++ b/ballerina/CompilerPlugin.toml @@ -3,4 +3,4 @@ id = "log-compiler-plugin" class = "io.ballerina.stdlib.log.compiler.LogCompilerPlugin" [[dependency]] -path = "../compiler-plugin/build/libs/log-compiler-plugin-2.13.0.jar" +path = "../compiler-plugin/build/libs/log-compiler-plugin-2.14.0-SNAPSHOT.jar" diff --git a/ballerina/Dependencies.toml b/ballerina/Dependencies.toml index b869132d..040bfb8a 100644 --- a/ballerina/Dependencies.toml +++ b/ballerina/Dependencies.toml @@ -76,7 +76,7 @@ modules = [ [[package]] org = "ballerina" name = "log" -version = "2.13.0" +version = "2.14.0" dependencies = [ {org = "ballerina", name = "io"}, {org = "ballerina", name = "jballerina.java"}, diff --git a/ballerina/README.md b/ballerina/README.md index 6f8edae0..ad840fc6 100644 --- a/ballerina/README.md +++ b/ballerina/README.md @@ -113,3 +113,92 @@ The log module supports contextual logging, allowing you to create loggers with ``` For more details and advanced usage, see the module specification and API documentation. + +## Sensitive Data Masking + +The log module provides capabilities to mask sensitive data in log messages to maintain data privacy and security when dealing with personally identifiable information (PII) or other sensitive data. + +> **Note**: By default, sensitive data masking is disabled. Enable it in `Config.toml`: +> +> ```toml +> [ballerina.log] +> enableSensitiveDataMasking = true +> ``` +> +> Or configure it per logger: +> +> ```ballerina +> log:Config secureConfig = { +> enableSensitiveDataMasking: true +> }; +> log:Logger secureLogger = log:fromConfig(secureConfig); +> ``` + +### Sensitive Data Annotation + +Use the `@log:Sensitive` annotation to mark fields in records as sensitive. When such fields are logged, their values will be excluded or masked: + +```ballerina +import ballerina/log; + +type User record { + string id; + @log:Sensitive + string password; + string name; +}; + +public function main() { + User user = {id: "U001", password: "mypassword", name: "John Doe"}; + log:printInfo("user details", user = user); +} +``` + +Output (with masking enabled): + +```log +time=2025-08-20T09:15:30.123+05:30 level=INFO module="" message="user details" user={"id":"U001","name":"John Doe"} +``` + +### Masking Strategies + +Configure masking strategies using the `strategy` field: + +```ballerina +import ballerina/log; + +isolated function maskString(string input) returns string { + if input.length() <= 2 { + return "****"; + } + return input.substring(0, 1) + "****" + input.substring(input.length() - 1); +} + +type User record { + string id; + @log:Sensitive { + strategy: { + replacement: "****" + } + } + string password; + @log:Sensitive { + strategy: { + replacement: maskString + } + } + string ssn; + string name; +}; +``` + +### Masked String Function + +Use `log:toMaskedString()` to get the masked version of a value for custom logging implementations: + +```ballerina +User user = {id: "U001", password: "mypassword", name: "John Doe"}; +string maskedUser = log:toMaskedString(user); +io:println(maskedUser); // {"id":"U001","name":"John Doe"} +``` + diff --git a/ballerina/natives.bal b/ballerina/natives.bal index e17c9165..eadc5f9d 100644 --- a/ballerina/natives.bal +++ b/ballerina/natives.bal @@ -166,6 +166,10 @@ public enum FileWriteOption { # # + template - The raw template to be processed # + return - The processed string +# +# # Deprecated +# The `processTemplate` function is deprecated. Use `evaluateTemplate` instead. +@deprecated public isolated function processTemplate(PrintableRawTemplate template) returns string { string[] templateStrings = template.strings; Value[] insertions = template.insertions; @@ -183,8 +187,30 @@ public isolated function processTemplate(PrintableRawTemplate template) returns return result; } -isolated function processMessage(string|PrintableRawTemplate msg) returns string => - msg !is string ? processTemplate(msg) : msg; +# Evaluates the raw template and returns the evaluated string. +# +# + template - The raw template to be evaluated +# + enableSensitiveDataMasking - Flag to indicate if sensitive data masking is enabled +# + return - The evaluated string +public isolated function evaluateTemplate(PrintableRawTemplate template, boolean enableSensitiveDataMasking = false) returns string { + string[] templateStrings = template.strings; + Value[] insertions = template.insertions; + string result = templateStrings[0]; + + foreach int i in 1 ..< templateStrings.length() { + Value insertion = insertions[i - 1]; + string insertionStr = insertion is PrintableRawTemplate ? + evaluateTemplate(insertion, enableSensitiveDataMasking) : + insertion is Valuer ? + (enableSensitiveDataMasking ? toMaskedString(insertion()) : insertion().toString()) : + (enableSensitiveDataMasking ? toMaskedString(insertion) : insertion.toString()); + result += insertionStr + templateStrings[i]; + } + return result; +} + +isolated function processMessage(string|PrintableRawTemplate msg, boolean enableSensitiveDataMasking) returns string => + msg !is string ? evaluateTemplate(msg, enableSensitiveDataMasking) : msg; # Prints debug logs. # ```ballerina @@ -349,7 +375,7 @@ isolated function fileWrite(string logOutput) { } } -isolated function printLogFmt(LogRecord logRecord) returns string { +isolated function printLogFmt(LogRecord logRecord, boolean enableSensitiveDataMasking = false) returns string { string message = ""; foreach [string, anydata] [k, v] in logRecord.entries() { string value; @@ -367,7 +393,8 @@ isolated function printLogFmt(LogRecord logRecord) returns string { value = v.toBalString(); } _ => { - value = v is string ? string `${escape(v.toString())}` : v.toString(); + string strValue = enableSensitiveDataMasking ? toMaskedString(v) : v.toString(); + value = v is string ? string `${escape(strValue)}` : strValue; } } if message == "" { diff --git a/ballerina/root_logger.bal b/ballerina/root_logger.bal index 1ac6a692..01ae0551 100644 --- a/ballerina/root_logger.bal +++ b/ballerina/root_logger.bal @@ -27,6 +27,8 @@ public type Config record {| readonly & OutputDestination[] destinations = destinations; # Additional key-value pairs to include in the log messages. Default is the key-values configured in the module level readonly & AnydataKeyValues keyValues = {...keyValues}; + # Enable sensitive data masking. Default is the module level configuration + boolean enableSensitiveDataMasking = enableSensitiveDataMasking; |}; type ConfigInternal record {| @@ -34,6 +36,7 @@ type ConfigInternal record {| Level level = level; readonly & OutputDestination[] destinations = destinations; readonly & KeyValues keyValues = {...keyValues}; + boolean enableSensitiveDataMasking = enableSensitiveDataMasking; |}; final RootLogger rootLogger; @@ -59,7 +62,8 @@ public isolated function fromConfig(*Config config) returns Logger|Error { format: config.format, level: config.level, destinations: config.destinations, - keyValues: newKeyValues.cloneReadOnly() + keyValues: newKeyValues.cloneReadOnly(), + enableSensitiveDataMasking: config.enableSensitiveDataMasking }; return new RootLogger(newConfig); } @@ -71,12 +75,14 @@ isolated class RootLogger { private final Level level; private final readonly & OutputDestination[] destinations; private final readonly & KeyValues keyValues; + private final boolean enableSensitiveDataMasking; public isolated function init(Config|ConfigInternal config = {}) { self.format = config.format; self.level = config.level; self.destinations = config.destinations; self.keyValues = config.keyValues; + self.enableSensitiveDataMasking = config.enableSensitiveDataMasking; } public isolated function printDebug(string|PrintableRawTemplate msg, error? 'error, error:StackFrame[]? stackTrace, *KeyValues keyValues) { @@ -108,7 +114,8 @@ isolated class RootLogger { format: self.format, level: self.level, destinations: self.destinations, - keyValues: newKeyValues.cloneReadOnly() + keyValues: newKeyValues.cloneReadOnly(), + enableSensitiveDataMasking: self.enableSensitiveDataMasking }; return new RootLogger(config); } @@ -121,7 +128,7 @@ isolated class RootLogger { time: getCurrentTime(), level: logLevel, module: moduleName, - message: processMessage(msg) + message: processMessage(msg, self.enableSensitiveDataMasking) }; if err is error { logRecord.'error = getFullErrorDetails(err); @@ -131,7 +138,8 @@ isolated class RootLogger { select element.toString(); } foreach [string, Value] [k, v] in keyValues.entries() { - logRecord[k] = v is Valuer ? v() : v is PrintableRawTemplate ? processMessage(v) : v; + logRecord[k] = v is Valuer ? v() : + (v is PrintableRawTemplate ? evaluateTemplate(v, self.enableSensitiveDataMasking) : v); } if observe:isTracingEnabled() { map spanContext = observe:getSpanContext(); @@ -140,10 +148,13 @@ isolated class RootLogger { } } foreach [string, Value] [k, v] in self.keyValues.entries() { - logRecord[k] = v is Valuer ? v() : v is PrintableRawTemplate ? processMessage(v) : v; + logRecord[k] = v is Valuer ? v() : + (v is PrintableRawTemplate ? evaluateTemplate(v, self.enableSensitiveDataMasking) : v); } - string logOutput = self.format == JSON_FORMAT ? logRecord.toJsonString() : printLogFmt(logRecord); + string logOutput = self.format == JSON_FORMAT ? + (self.enableSensitiveDataMasking ? toMaskedString(logRecord) : logRecord.toJsonString()) : + printLogFmt(logRecord, self.enableSensitiveDataMasking); lock { if outputFilePath is string { diff --git a/ballerina/sensitive_data_masking.bal b/ballerina/sensitive_data_masking.bal new file mode 100644 index 00000000..f4d13dc2 --- /dev/null +++ b/ballerina/sensitive_data_masking.bal @@ -0,0 +1,54 @@ +// Copyright (c) 2025 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import ballerina/jballerina.java; + +# Exclude the field from log output +public const EXCLUDE = "EXCLUDE"; + +# Replacement function type for sensitive data masking +public type ReplacementFunction isolated function (string input) returns string; + +# Replacement strategy for sensitive data +public type Replacement record {| + # The replacement value. This can be a string which will be used to replace the + # entire value, or a function that takes the original value and returns a masked version. + string|ReplacementFunction replacement; +|}; + +# Masking strategy for sensitive data +public type MaskingStrategy EXCLUDE|Replacement; + +# Represents sensitive data with a masking strategy +public type SensitiveConfig record {| + # The masking strategy to apply + MaskingStrategy strategy = EXCLUDE; +|}; + +# Marks a record field or type as sensitive, excluding it from log output +# The default strategy is to exclude the field from log output +public annotation SensitiveConfig Sensitive on record field; + +configurable boolean enableSensitiveDataMasking = false; + +# Returns a masked string representation of the given data based on the sensitive data masking annotation. +# This method panics if a cyclic value reference is encountered. +# +# + data - The data to be masked +# + return - The masked string representation of the data +public isolated function toMaskedString(anydata data) returns string = @java:Method { + 'class: "io.ballerina.stdlib.log.Utils" +} external; diff --git a/ballerina/tests/log_masking.bal b/ballerina/tests/log_masking.bal new file mode 100644 index 00000000..e58b5fe3 --- /dev/null +++ b/ballerina/tests/log_masking.bal @@ -0,0 +1,90 @@ +// Copyright (c) 2025 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import ballerina/io; +import ballerina/test; + +final Logger maskerJsonLogger = check fromConfig(enableSensitiveDataMasking = true); +final Logger maskerLogger = check fromConfig(enableSensitiveDataMasking = true, format = LOGFMT); + +string[] maskedLogs = []; + +function addMaskedLogs(io:FileOutputStream fileOutputStream, io:Printable... values) { + var firstValue = values[0]; + if firstValue is string { + maskedLogs.push(firstValue); + } +} + +final readonly & User user = { + name: "John Doe", + ssn: "123-45-6789", + password: "password123", + mail: "john.doe@example.com", + creditCard: "4111-1111-1111-1111" +}; + +isolated function getUser() returns User => user; + +@test:Config { + groups: ["logMasking"] +} +function testLogMasking() returns error? { + test:when(mock_fprintln).call("addMaskedLogs"); + maskerJsonLogger.printInfo("user logged in", user = user); + string expectedLog = string `"message":"user logged in","user":{"name":"John Doe","password":"*****","mail":"joh**************com"},"env":"test"`; + test:assertEquals(maskedLogs.length(), 1); + test:assertTrue(maskedLogs[0].includes(expectedLog)); + maskedLogs.removeAll(); + + maskerLogger.printInfo("user logged in", user = user); + expectedLog = string `message="user logged in" user={"name":"John Doe","password":"*****","mail":"joh**************com"} env="test"`; + test:assertEquals(maskedLogs.length(), 1); + test:assertTrue(maskedLogs[0].includes(expectedLog)); + maskedLogs.removeAll(); + + map userTmp = user; + maskerJsonLogger.printInfo("user logged in", user = userTmp); + expectedLog = string `"message":"user logged in","user":{"name":"John Doe","password":"*****","mail":"joh**************com"},"env":"test"`; + test:assertEquals(maskedLogs.length(), 1); + test:assertTrue(maskedLogs[0].includes(expectedLog)); + maskedLogs.removeAll(); + + userTmp = check user.cloneWithType(); + maskerJsonLogger.printInfo("user logged in", user = userTmp); + expectedLog = string `"message":"user logged in","user":{"name":"John Doe","ssn":"123-45-6789","password":"password123","mail":"john.doe@example.com","creditCard":"4111-1111-1111-1111"},"env":"test"`; + test:assertEquals(maskedLogs.length(), 1); + test:assertTrue(maskedLogs[0].includes(expectedLog)); + maskedLogs.removeAll(); + + maskerLogger.printDebug(`user login event. user details: ${user}`); + expectedLog = string `message="user login event. user details: {\"name\":\"John Doe\",\"password\":\"*****\",\"mail\":\"joh**************com\"}" env="test"`; + test:assertEquals(maskedLogs.length(), 1); + test:assertTrue(maskedLogs[0].includes(expectedLog)); + maskedLogs.removeAll(); + + maskerLogger.printWarn("user login attempt failed", user = getUser); + expectedLog = string `message="user login attempt failed" user={"name":"John Doe","password":"*****","mail":"joh**************com"} env="test"`; + test:assertEquals(maskedLogs.length(), 1); + test:assertTrue(maskedLogs[0].includes(expectedLog)); + maskedLogs.removeAll(); + + maskerLogger.printInfo("basic types", str = "my string", num = 12345, flag = true, val = (), xmlVal = xml `bar`); + expectedLog = string `message="basic types" str="my string" num=12345 flag=true val=null xmlVal=bar env="test"`; + test:assertEquals(maskedLogs.length(), 1); + test:assertTrue(maskedLogs[0].includes(expectedLog)); + maskedLogs.removeAll(); +} diff --git a/ballerina/tests/logger_test.bal b/ballerina/tests/logger_test.bal index b3bef0ce..d7a99108 100644 --- a/ballerina/tests/logger_test.bal +++ b/ballerina/tests/logger_test.bal @@ -22,6 +22,9 @@ configurable Config loggerConfig2 = {}; type Context record {| string id; + // By default root logger is configured not to mask sensitive data + // So this is added as a negative test case + @Sensitive string msg; |}; diff --git a/ballerina/tests/masked_string_test.bal b/ballerina/tests/masked_string_test.bal new file mode 100644 index 00000000..2643c24f --- /dev/null +++ b/ballerina/tests/masked_string_test.bal @@ -0,0 +1,585 @@ +// Copyright (c) 2025 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import ballerina/test; + +isolated function maskStringPartially(string input) returns string { + int len = input.length(); + if len <= 6 { + return "******"; + } + string maskedString = input.substring(0, 3); + foreach int i in 3 ... len - 4 { + maskedString += "*"; + } + maskedString += input.substring(len - 3); + return maskedString; +}; + +function checkJsonParsing(string maskedStr) { + map|error parsedJson = maskedStr.fromJsonStringWithType(); + test:assertTrue(parsedJson is map); +} + +type User record {| + string name; + @Sensitive + string ssn; + @Sensitive {strategy: {replacement: "*****"}} + string password; + @Sensitive {strategy: {replacement: maskStringPartially}} + string mail; + @Sensitive {strategy: EXCLUDE} + string creditCard; +|}; + +@test:Config { + groups: ["maskedString"] +} +function testMaskedString() { + User user = { + name: "John Doe", + ssn: "123-45-6789", + password: "password123", + mail: "john.doe@example.com", + creditCard: "4111-1111-1111-1111" + }; + string maskedUserStr = toMaskedString(user); + string expectedStr = string `{"name":"John Doe","password":"*****","mail":"joh**************com"}`; + test:assertEquals(maskedUserStr, expectedStr); +} + +type RecordWithAnydataValues record {| + string str; + int|float num; + boolean bool; + map jsonMap; + table> tableData; + anydata[] arr; + xml xmlRaw; + xml:Text xmlText; + [int, float, string] tuple; +|}; + +@test:Config { + groups: ["maskedString"] +} +function testMaskedStringWithAnydataValues() { + RecordWithAnydataValues anydataRec = { + str: "Test String", + num: 123.45, + bool: true, + jsonMap: {key1: "value1", key2: 2}, + tableData: table [ + {col1: "row1col1", col2: "row1col2"}, + {col1: "row2col1", col2: "row2col2"} + ], + arr: ["elem1", 2, {key: "value"}], + xmlRaw: xml `UserAdminReminderDon't forget the meeting!`, + xmlText: xml `Just some text`, + tuple: [1, 2.5, "three"] + }; + string maskedAnydataRecStr = toMaskedString(anydataRec); + string expectedStr = string `{"str":"Test String","num":123.45,"bool":true,"jsonMap":{"key1":"value1","key2":2},"tableData":[{"col1":"row1col1","col2":"row1col2"},{"col1":"row2col1","col2":"row2col2"}],"arr":["elem1",2,{"key":"value"}],"xmlRaw":"UserAdminReminderDon't forget the meeting!","xmlText":"Just some text","tuple":[1,2.5,"three"]}`; + test:assertEquals(maskedAnydataRecStr, expectedStr); + checkJsonParsing(maskedAnydataRecStr); +} + +type OpenAnydataRecord record { + string name; + @Sensitive + anydata sensitiveField; +}; + +@test:Config { + groups: ["maskedString"] +} +function testMaskedStringWithOpenAnydataRecord() { + OpenAnydataRecord fieldRec = { + name: "Field Record", + sensitiveField: "Sensitive Data", + "extraField": "extraValue" + }; + + OpenAnydataRecord openRec = { + name: "Open Record", + sensitiveField: {key1: "value1", key2: 2, key3: true}, + "extraField": "extraValue", + "extraMapField": {mapKey: "mapValue"}, + "extraArrayField": [1, "two", 3.0], + "extraRecordField": fieldRec + }; + string maskedOpenRecStr = toMaskedString(openRec); + string expectedStr = string `{"name":"Open Record","extraField":"extraValue","extraMapField":{"mapKey":"mapValue"},"extraArrayField":[1,"two",3.0],"extraRecordField":{"name":"Field Record","extraField":"extraValue"}}`; + test:assertEquals(maskedOpenRecStr, expectedStr); + checkJsonParsing(maskedOpenRecStr); +} + +type Record1 record {| + string field1; + @Sensitive + Record2 field2; + Record2 field3; +|}; + +type Record2 record {| + string subField1; + @Sensitive {strategy: {replacement: "###"}} + string subField2; + @Sensitive {strategy: {replacement: maskStringPartially}} + string subField3; + @Sensitive {strategy: EXCLUDE} + string subField4; +|}; + +type Record3 record {| + string info; + @Sensitive + string details; +|}; + +type NestedRecord record {| + string name; + @Sensitive + Record1 details1; + Record1 details2; + Record3[] records; +|}; + +@test:Config { + groups: ["maskedString"] +} +function testMaskedStringWithNestedRecords() { + NestedRecord nestedRec = { + name: "Nested Record", + details1: { + field1: "Field1 Value", + field2: { + subField1: "SubField1 Value", + subField2: "SubField2 Value", + subField3: "SubField3 Value", + subField4: "SubField4 Value" + }, + field3: { + subField1: "SubField1 Value", + subField2: "SubField2 Value", + subField3: "SubField3 Value", + subField4: "SubField4 Value" + } + }, + details2: { + field1: "Field1 Value", + field2: { + subField1: "SubField1 Value", + subField2: "SubField2 Value", + subField3: "SubField3 Value", + subField4: "SubField4 Value" + }, + field3: { + subField1: "SubField1 Value", + subField2: "SubField2 Value", + subField3: "SubField3 Value", + subField4: "SubField4 Value" + } + }, + records: [ + {info: "Record1 Info", details: "Record1 Details"}, + {info: "Record2 Info", details: "Record2 Details"} + ] + }; + string maskedNestedRecStr = toMaskedString(nestedRec); + string expectedStr = string `{"name":"Nested Record","details2":{"field1":"Field1 Value","field3":{"subField1":"SubField1 Value","subField2":"###","subField3":"Sub*********lue"}},"records":[{"info":"Record1 Info"},{"info":"Record2 Info"}]}`; + test:assertEquals(maskedNestedRecStr, expectedStr); + checkJsonParsing(maskedNestedRecStr); +} + +type NilableSensitiveFieldRecord record {| + string name; + @Sensitive + string? sensitiveField; + int? id; +|}; + +@test:Config { + groups: ["maskedString"] +} +function testMaskedStringWithNilableSensitiveField() { + NilableSensitiveFieldRecord recWithNil = { + name: "Nilable Record", + sensitiveField: (), + id: null + }; + string maskedRecWithNilStr = toMaskedString(recWithNil); + string expectedStr = string `{"name":"Nilable Record","id":null}`; + test:assertEquals(maskedRecWithNilStr, expectedStr); + checkJsonParsing(maskedRecWithNilStr); +} + +type OptionalSensitiveFieldRecord record {| + string name; + @Sensitive + string sensitiveField?; + int id?; +|}; + +@test:Config { + groups: ["maskedString"] +} +function testMaskedStringWithOptionalSensitiveField() { + OptionalSensitiveFieldRecord recWithOptional = { + name: "Optional Record", + id: 101 + }; + string maskedRecWithOptionalStr = toMaskedString(recWithOptional); + string expectedStr = string `{"name":"Optional Record","id":101}`; + test:assertEquals(maskedRecWithOptionalStr, expectedStr); + checkJsonParsing(maskedRecWithOptionalStr); + + recWithOptional.sensitiveField = "Sensitive Data"; + string maskedRecWithOptionalSetStr = toMaskedString(recWithOptional); + test:assertEquals(maskedRecWithOptionalSetStr, expectedStr); + checkJsonParsing(maskedRecWithOptionalSetStr); + + recWithOptional.id = (); + string maskedRecWithOptionalSetNilStr = toMaskedString(recWithOptional); + string expectedStrWithoutId = string `{"name":"Optional Record"}`; + test:assertEquals(maskedRecWithOptionalSetNilStr, expectedStrWithoutId); + checkJsonParsing(maskedRecWithOptionalSetNilStr); +} + +type NeverSensitiveFieldRecord record {| + string name; + @Sensitive + never sensitiveField?; +|}; + +@test:Config { + groups: ["maskedString"] +} +function testMaskedStringWithNeverSensitiveField() { + NeverSensitiveFieldRecord rec = { + name: "Never Record", + sensitiveField: () + }; + string maskedRecStr = toMaskedString(rec); + string expectedStr = string `{"name":"Never Record"}`; + test:assertEquals(maskedRecStr, expectedStr); + checkJsonParsing(maskedRecStr); +} + +type RecordWithRestField record {| + string name; + @Sensitive + string sensitiveField; + string...; +|}; + +@test:Config { + groups: ["maskedString"] +} +function testMaskedStringWithRestField() { + RecordWithRestField rec = { + name: "Rest Field Record", + sensitiveField: "Sensitive Data", + "extraField1": "extraValue1", + "extraField2": "extraValue2" + }; + string maskedRecStr = toMaskedString(rec); + string expectedStr = string `{"name":"Rest Field Record","extraField1":"extraValue1","extraField2":"extraValue2"}`; + test:assertEquals(maskedRecStr, expectedStr); + checkJsonParsing(maskedRecStr); +} + +type CyclicRecord record {| + string name; + CyclicRecord child?; +|}; + +@test:Config { + groups: ["maskedString"] +} +function testMaskedStringWithCyclicRecord() { + CyclicRecord rec = { + name: "name" + }; + rec.child = rec; + string|error maskedRecStr = trap toMaskedString(rec); + if maskedRecStr is string { + test:assertFail("Expected an error due to cyclic value reference, but got a string"); + } + test:assertEquals(maskedRecStr.message(), "Cyclic value reference detected in the record"); +} + +type RecordWithCyclicSensitiveField record {| + string name; + @Sensitive + RecordWithCyclicSensitiveField child?; +|}; + +@test:Config { + groups: ["maskedString"] +} +function testMaskedStringWithCyclicSensitiveField() { + RecordWithCyclicSensitiveField rec = { + name: "name" + }; + rec.child = rec; + string maskedRecStr = toMaskedString(rec); + string expectedStr = string `{"name":"name"}`; + test:assertEquals(maskedRecStr, expectedStr); + checkJsonParsing(maskedRecStr); +} + +@test:Config { + groups: ["maskedString"] +} +function testMaskedStringWithMap() { + map jsonMap = { + key1: "value1", + key2: 2, + key3: true, + key4: {nestedKey: "nestedValue"}, + key5: [1, "two", 3.0] + }; + string maskedMapStr = toMaskedString(jsonMap); + string expectedStr = string `{"key1":"value1","key2":2,"key3":true,"key4":{"nestedKey":"nestedValue"},"key5":[1,"two",3.0]}`; + test:assertEquals(maskedMapStr, expectedStr); + checkJsonParsing(maskedMapStr); + + User user = { + name: "John Doe", + ssn: "123-45-6789", + password: "password123", + mail: "john.doe@example.com", + creditCard: "4111-1111-1111-1111" + }; + map mapWithSensitiveData = { + normalKey: "normalValue", + sensitiveKey: user + }; + string maskedMapWithSensitiveDataStr = toMaskedString(mapWithSensitiveData); + string expectedMapWithSensitiveDataStr = string `{"normalKey":"normalValue","sensitiveKey":{"name":"John Doe","password":"*****","mail":"joh**************com"}}`; + test:assertEquals(maskedMapWithSensitiveDataStr, expectedMapWithSensitiveDataStr); + checkJsonParsing(maskedMapWithSensitiveDataStr); +} + +type SpecialCharFieldsRec record {| + string field_with_underscores; + string FieldWithCamelCase; + string field\-With\$pecialChar\!; + string 'type; + string 'value\\\-Field; +|}; + +type SpecialCharSensitiveFieldsRec record {| + @Sensitive {strategy: {replacement: "*****"}} + string field_with_underscores; + @Sensitive {strategy: {replacement: "#####"}} + string FieldWithCamelCase; + @Sensitive {strategy: {replacement: "1!1!1!"}} + string field\-With\$pecialChar\!; + @Sensitive {strategy: {replacement: "[REDACTED]"}} + string 'type; + @Sensitive {strategy: {replacement: "~~~~~~"}} + string 'value\\\-Field; +|}; + +@test:Config { + groups: ["maskedString"] +} +function testMaskedStringWithSpecialCharFieldsAndSpecialCharValues() { + SpecialCharFieldsRec rec = { + field_with_underscores: "\"value1\",\"value2\"", + FieldWithCamelCase: "value2", + field\-With\$pecialChar\!: "value3 & 'value4' ", + 'type: "exampleType\n\t", + 'value\\\-Field: "value" + }; + string maskedRecStr = toMaskedString(rec); + string expectedStr = string `{"field_with_underscores":"\"value1\",\"value2\"","FieldWithCamelCase":"value2","field-With$pecialChar!":"value3 & 'value4' ","type":"exampleType\n\t","value\\-Field":"value"}`; + test:assertEquals(maskedRecStr, expectedStr); + checkJsonParsing(maskedRecStr); +} + +@test:Config { + groups: ["maskedString"] +} +function testMaskedStringWithSpecialCharFields() { + SpecialCharSensitiveFieldsRec rec = { + field_with_underscores: "\"value1\",\"value2\"", + FieldWithCamelCase: "value2", + field\-With\$pecialChar\!: "value3 & 'value4' ", + 'type: "exampleType\n\t", + 'value\\\-Field: "value" + }; + string maskedRecStr = toMaskedString(rec); + string expectedStr = string `{"field_with_underscores":"*****","FieldWithCamelCase":"#####","field-With$pecialChar!":"1!1!1!","type":"[REDACTED]","value\\-Field":"~~~~~~"}`; + test:assertEquals(maskedRecStr, expectedStr); + checkJsonParsing(maskedRecStr); +} + +type ReadonlyUser1 readonly & record {| + string name; + @Sensitive + string ssn; + @Sensitive {strategy: {replacement: "*****"}} + string password; + @Sensitive {strategy: {replacement: maskStringPartially}} + string mail; + @Sensitive {strategy: EXCLUDE} + string creditCard; +|}; + +type ReadonlyUser2 readonly & User; + +@test:Config { + groups: ["maskedString"] +} +function testMaskedStringWithReadonlyRecords() returns error? { + User user = { + name: "John Doe", + ssn: "123-45-6789", + password: "password123", + mail: "john.doe@example.com", + creditCard: "4111-1111-1111-1111" + }; + + ReadonlyUser1 readonlyUser1 = {...user}; + ReadonlyUser2 readonlyUser2 = {...user}; + readonly & User readonlyUser3 = {...user}; + ReadonlyUser1 readonlyUser4 = check user.cloneWithType(); + ReadonlyUser2 readonlyUser5 = check user.cloneWithType(); + readonly & User readonlyUser6 = check user.cloneWithType(); + readonly & User readonlyUser7 = user.cloneReadOnly(); + + string maskedReadonlyUser1Str = toMaskedString(readonlyUser1); + string maskedReadonlyUser2Str = toMaskedString(readonlyUser2); + string maskedReadonlyUser3Str = toMaskedString(readonlyUser3); + string maskedReadonlyUser4Str = toMaskedString(readonlyUser4); + string maskedReadonlyUser5Str = toMaskedString(readonlyUser5); + string maskedReadonlyUser6Str = toMaskedString(readonlyUser6); + string maskedReadonlyUser7Str = toMaskedString(readonlyUser7); + + string expectedStr = string `{"name":"John Doe","password":"*****","mail":"joh**************com"}`; + + test:assertEquals(maskedReadonlyUser1Str, expectedStr); + test:assertEquals(maskedReadonlyUser2Str, expectedStr); + test:assertEquals(maskedReadonlyUser3Str, expectedStr); + test:assertEquals(maskedReadonlyUser4Str, expectedStr); + test:assertEquals(maskedReadonlyUser5Str, expectedStr); + test:assertEquals(maskedReadonlyUser6Str, expectedStr); + test:assertEquals(maskedReadonlyUser7Str, expectedStr); + + checkJsonParsing(maskedReadonlyUser1Str); + checkJsonParsing(maskedReadonlyUser2Str); + checkJsonParsing(maskedReadonlyUser3Str); + checkJsonParsing(maskedReadonlyUser4Str); + checkJsonParsing(maskedReadonlyUser5Str); + checkJsonParsing(maskedReadonlyUser6Str); + checkJsonParsing(maskedReadonlyUser7Str); +} + +type StructurallySimilarUser record {| + string name; + string ssn; + string password; + string mail; + string creditCard; +|}; + +@test:Config { + groups: ["maskedString"] +} +function testMaskedStringWithStructurallySimilarRecord() returns error? { + User user = { + name: "John Doe", + ssn: "123-45-6789", + password: "password123", + mail: "john.doe@example.com", + creditCard: "4111-1111-1111-1111" + }; + + StructurallySimilarUser similarUser = user; + string maskedSimilarUserStr = toMaskedString(similarUser); + string expectedStr = string `{"name":"John Doe","password":"*****","mail":"joh**************com"}`; + test:assertEquals(maskedSimilarUserStr, expectedStr); + checkJsonParsing(maskedSimilarUserStr); + + similarUser = { + name: "John Doe", + ssn: "123-45-6789", + password: "password123", + mail: "john.doe@example.com", + creditCard: "4111-1111-1111-1111" + }; + maskedSimilarUserStr = toMaskedString(similarUser); + expectedStr = string `{"name":"John Doe","ssn":"123-45-6789","password":"password123","mail":"john.doe@example.com","creditCard":"4111-1111-1111-1111"}`; + test:assertEquals(maskedSimilarUserStr, expectedStr); + checkJsonParsing(maskedSimilarUserStr); + + user = similarUser; + string maskedUserStr = toMaskedString(user); + test:assertEquals(maskedUserStr, expectedStr); + checkJsonParsing(maskedUserStr); + + // Explicit type casting will not change the runtime type of the value for structural types + user = similarUser; + maskedUserStr = toMaskedString(user); + test:assertEquals(maskedUserStr, expectedStr); + checkJsonParsing(maskedUserStr); + + // Ensuretype will not change the runtime type of the value for structural types + user = check similarUser.ensureType(); + maskedUserStr = toMaskedString(user); + test:assertEquals(maskedUserStr, expectedStr); + checkJsonParsing(maskedUserStr); +} + +@test:Config { + groups: ["maskedString"] +} +function testMaskedStringWithBasicTypes() { + test:assertEquals(toMaskedString(()), "null"); + test:assertEquals(toMaskedString("Test String"), "Test String"); + test:assertEquals(toMaskedString(123), "123"); + test:assertEquals(toMaskedString(45.67), "45.67"); + test:assertEquals(toMaskedString(45.67d), "45.67"); + test:assertEquals(toMaskedString(true), "true"); + test:assertEquals(toMaskedString(xml `UserAdminReminderDon't forget the meeting!`), "UserAdminReminderDon't forget the meeting!"); + test:assertEquals(toMaskedString(xml `Just some text`), "Just some text"); + test:assertEquals(toMaskedString([]), "[]"); + test:assertEquals(toMaskedString(table []), "[]"); + test:assertEquals(toMaskedString({"list": []}), string `{"list":[]}`); + record{} emptyRec = {}; + test:assertEquals(toMaskedString(emptyRec), "{}"); +} + +@test:Config { + groups: ["maskedString"] +} +function testMaskedStringWithCharactersToBeEscaped() { + record {} specialCharMap = { + "quote": "\"DoubleQuote\"", + "backslash": "Back\\slash", + "newline": "New\nLine", + "tab": "Tab\tCharacter", + "carriageReturn": "Carriage\rReturn" + }; + string maskedMapStr = toMaskedString(specialCharMap); + string expectedStr = string `{"quote":"\"DoubleQuote\"","backslash":"Back\\slash","newline":"New\nLine","tab":"Tab\tCharacter","carriageReturn":"Carriage\rReturn"}`; + test:assertEquals(maskedMapStr, expectedStr); + checkJsonParsing(maskedMapStr); +} diff --git a/changelog.md b/changelog.md index 5c6a58fd..6c61536d 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,12 @@ # Change Log This file contains all the notable changes done to the Ballerina TCP package through the releases. +## [Unreleased] + +### Added + +- [Introduce sensitive data masking support](https://github.com/ballerina-platform/ballerina-library/issues/8211) + ## [2.13.0]- 2025-08-28 ### Added diff --git a/docs/spec/spec.md b/docs/spec/spec.md index ab629c75..a84a2ab1 100644 --- a/docs/spec/spec.md +++ b/docs/spec/spec.md @@ -3,7 +3,7 @@ _Authors_: @daneshk @MadhukaHarith92 @TharmiganK _Reviewers_: @daneshk @ThisaruGuruge _Created_: 2021/11/15 -_Updated_: 2025/08/20 +_Updated_: 2025/10/08 _Edition_: Swan Lake ## Introduction @@ -31,6 +31,10 @@ The conforming implementation of the specification is released and included in t * 4.3. [Child logger](#43-child-logger) * 4.3.1. [Loggers with additional context](#431-loggers-with-additional-context) * 4.3.2. [Loggers with unique logging configuration](#432-loggers-with-unique-logging-configuration) +5. [Sensitive data masking](#5-sensitive-data-masking) + * 5.1. [Sensitive data annotation](#51-sensitive-data-annotation) + * 5.2. [Masked string function](#52-masked-string-function) + * 5.3. [Type-based masking](#53-type-based-masking) ## 1. Overview @@ -224,10 +228,10 @@ public type Logger isolated object { }; ``` -> **Note:** The Ballerina log module provides a function to process the PrintableRawTemplate to obtain the processed string. This can be used when implementing a logger from the above type. +> **Note:** The Ballerina log module provides a function to evaluate the `PrintableRawTemplate` to obtain the evaluated string. This can be used when implementing a logger from the above type. > > ```ballerina -> public isolated function processTemplate(PrintableRawTemplate) returns string; +> public isolated function evaluateTemplate(PrintableRawTemplate rawTemplate, boolean enableSensitiveDataMasking = false) returns string; > ``` ### 4.2. Root logger @@ -279,6 +283,8 @@ public type Config record {| readonly & OutputDestination[] destinations = destinations; # Additional key-value pairs to include in the log messages. Default is the key-values configured in the module level readonly & AnydataKeyValues keyValues = {...keyValues}; + # Enable sensitive data masking in the logs. Default is false + boolean enableSensitiveDataMasking = false; |}; ``` @@ -294,3 +300,165 @@ log:Config auditLogConfig = { log:Logger auditLogger = log:fromConfig(auditLogConfig); auditLogger.printInfo("Hello World from the audit logger!"); ``` + +## 5. Sensitive data masking + +The Ballerina log module provides the capability to mask sensitive data in log messages. This is crucial for maintaining data privacy and security, especially when dealing with personally identifiable information (PII) or other sensitive data. + +> **Note**: By default, sensitive data masking is disabled. Enable it in `Config.toml`: +> +> ```toml +> [ballerina.log] +> enableSensitiveDataMasking = true +> ``` +> +> Or configure it per logger: +> +> ```ballerina +> log:Config secureConfig = { +> enableSensitiveDataMasking: true +> }; +> log:Logger secureLogger = log:fromConfig(secureConfig); +> ``` + +### 5.1. Sensitive data annotation + +The `@log:Sensitive` annotation can be used to mark fields in a record as sensitive. When such fields are logged, their values will be excluded or masked to prevent exposure of sensitive information. + +```ballerina +import ballerina/log; + +type User record { + string id; + @log:Sensitive + string password; + string name; +}; + +public function main() { + User user = {id: "U001", password: "mypassword", name: "John Doe"}; + log:printInfo("user details", user = user); +} +``` + +Output: + +```log +time=2025-08-20T09:15:30.123+05:30 level=INFO module="" message="user details" user={"id":"U001","name":"John Doe"} +``` + +The `@log:Sensitive` annotation will exclude the sensitive field from the log output when sensitive data masking is enabled. + +Additionally, the masking strategy can be configured using the `strategy` field of the annotation. The available strategies are: +1. `EXCLUDE`: Excludes the field from the log output (default behavior). +2. `Replacement`: Replaces the field value with a specified replacement string or a function that generates a masked version of the value. + +Example: + +```ballerina +import ballerina/log; + +isolated function maskString(string input) returns string { + if input.length() <= 2 { + return "****"; + } + return input.substring(0, 1) + "****" + input.substring(input.length() - 1); +} + +type User record { + string id; + @log:Sensitive { + strategy: { + replacement: "****" + } + } + string password; + @log:Sensitive { + strategy: { + replacement: maskString + } + } + string ssn; + string name; +}; + +public function main() { + User user = {id: "U001", password: "mypassword", ssn: "123-45-6789", name: "John Doe"}; + log:printInfo("user details", user = user); +} +``` + +Output: + +```log +time=2025-08-20T09:20:45.456+05:30 level=INFO module="" message="user details" user={"id":"U001","password":"****","ssn":"1****9","name":"John Doe"} +``` + +### 5.2. Masked string function + +The `log:toMaskedString()` function can be used to obtain the masked version of a value. This is useful when developers want to implement custom loggers and need to mask sensitive data. + +```ballerina +import ballerina/log; +import ballerina/io; + +type User record { + string id; + @log:Sensitive + string password; + string name; +}; + +public function main() { + User user = {id: "U001", password: "mypassword", name: "John Doe"}; + string maskedUser = log:toMaskedString(user); + io:println(maskedUser); +} +``` + +Output: + +```log +{"id":"U001","name":"John Doe"} +``` + +### 5.3. Type-based masking + +The masking is based on the type of the value. Since, Ballerina is a structurally typed language, same value can be assigned to different typed variables. So the masking is based on the actual value type which is determined at the value creation time. The original type information can be extracted using the `typeof` operator. + +Example: + +```ballerina +type User record { + string id; + @log:Sensitive + string password; + string name; +}; + +type Student record { + string id; + string password; // Not marked as sensitive + string name; +}; + +public function main() returns error? { + User user = {id: "U001", password: "mypassword", name: "John Doe"}; + // password will be masked + string maskedUser = log:toMaskedString(user); + + Student student = user; // Allowed since both have the same structure + // password will be masked since the type at value creation is User + string maskedStudent = log:toMaskedString(student); + + student = {id: "S001", password: "studentpass", name: "Jane Doe"}; + user = student; // Allowed since both have the same structure + // password will not be masked since the type at value creation is Student + maskedStudent = log:toMaskedString(user); + + // Explicity creating a value with type + user = check student.cloneWithType(); + // password will be masked since the type at value creation is User + maskedUser = log:toMaskedString(user); +} +``` diff --git a/gradle.properties b/gradle.properties index 3760f448..a46400c4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,6 @@ org.gradle.caching=true group=io.ballerina.stdlib -version=2.13.1-SNAPSHOT +version=2.14.0-SNAPSHOT ballerinaLangVersion=2201.12.0 checkstylePluginVersion=10.12.0 diff --git a/integration-tests/build.gradle b/integration-tests/build.gradle index b918811e..92aa4d91 100644 --- a/integration-tests/build.gradle +++ b/integration-tests/build.gradle @@ -117,6 +117,9 @@ task copyTestResources(type: Copy) { into("logger-from-config") { from "tests/resources/samples/logger/logger-from-config" } + into("masked-logger") { + from "tests/resources/samples/masked-logger" + } } task copyTestOutputResources(type: Copy) { diff --git a/integration-tests/tests/resources/samples/logger/custom-logger/main.bal b/integration-tests/tests/resources/samples/logger/custom-logger/main.bal index c3dad21a..23bd209a 100644 --- a/integration-tests/tests/resources/samples/logger/custom-logger/main.bal +++ b/integration-tests/tests/resources/samples/logger/custom-logger/main.bal @@ -70,7 +70,7 @@ isolated class CustomLogger { public isolated function withContext(*log:KeyValues keyValues) returns log:Logger|error { log:AnydataKeyValues newKeyValues = {...self.keyValues}; foreach [string, log:Value] [k, v] in keyValues.entries() { - newKeyValues[k] = v is log:Valuer ? v() : v is anydata ? v : log:processTemplate(v); + newKeyValues[k] = v is log:Valuer ? v() : v is anydata ? v : log:evaluateTemplate(v); } return new CustomLogger(filePath = self.filePath, level = self.level, keyValues = newKeyValues.cloneReadOnly()); } @@ -80,7 +80,7 @@ isolated class CustomLogger { return; } string timestamp = time:utcToEmailString(time:utcNow()); - string message = msg is string ? msg : log:processTemplate(msg); + string message = msg is string ? msg : log:evaluateTemplate(msg); string logMessage = string `[${timestamp}] {${level}} "${message}" `; if 'error is error { logMessage += string `error="${'error.message()}"`; @@ -95,7 +95,7 @@ isolated class CustomLogger { logMessage += string ` ${k}="${v.toString()}"`; } foreach [string, log:Value] [k, v] in keyValues.entries() { - anydata value = v is log:Valuer ? v() : v is anydata ? v : log:processTemplate(v); + anydata value = v is log:Valuer ? v() : v is anydata ? v : log:evaluateTemplate(v); logMessage += string ` ${k}="${value.toString()}"`; } logMessage += "\n"; diff --git a/integration-tests/tests/resources/samples/masked-logger/Ballerina.toml b/integration-tests/tests/resources/samples/masked-logger/Ballerina.toml new file mode 100644 index 00000000..dc338c58 --- /dev/null +++ b/integration-tests/tests/resources/samples/masked-logger/Ballerina.toml @@ -0,0 +1,4 @@ +[package] +org = "wso2" +name = "masked_logger" +version = "1.0.0" diff --git a/integration-tests/tests/resources/samples/masked-logger/Config.toml b/integration-tests/tests/resources/samples/masked-logger/Config.toml new file mode 100644 index 00000000..936f462f --- /dev/null +++ b/integration-tests/tests/resources/samples/masked-logger/Config.toml @@ -0,0 +1,3 @@ +[ballerina.log] +enableSensitiveDataMasking = true +level = "DEBUG" diff --git a/integration-tests/tests/resources/samples/masked-logger/main.bal b/integration-tests/tests/resources/samples/masked-logger/main.bal new file mode 100644 index 00000000..a61f3936 --- /dev/null +++ b/integration-tests/tests/resources/samples/masked-logger/main.bal @@ -0,0 +1,58 @@ +// Copyright (c) 2025 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import ballerina/log; + +type User record {| + string name; + @log:Sensitive + string ssn; + @log:Sensitive {strategy: {replacement: "*****"}} + string password; + @log:Sensitive {strategy: {replacement: maskStringPartially}} + string mail; + @log:Sensitive {strategy: log:EXCLUDE} + string creditCard; +|}; + +isolated function maskStringPartially(string input) returns string { + int len = input.length(); + if len <= 6 { + return "******"; + } + string maskedString = input.substring(0, 3); + foreach int i in 3 ... len - 4 { + maskedString += "*"; + } + maskedString += input.substring(len - 3); + return maskedString; +}; + +final readonly & User user = { + name: "John Doe", + ssn: "123-45-6789", + password: "P@ssw0rd!", + mail: "john.doe@example.com", + creditCard: "4111-1111-1111-1111" +}; + +isolated function getUser() returns User => user; + +public function main() { + log:printInfo("user logged in", userDetails = user); + log:printDebug(`user details: ${user}`); + log:printError("error occurred", userDetails = getUser); +} diff --git a/integration-tests/tests/test_logger_masking.bal b/integration-tests/tests/test_logger_masking.bal new file mode 100644 index 00000000..8e054127 --- /dev/null +++ b/integration-tests/tests/test_logger_masking.bal @@ -0,0 +1,39 @@ +// Copyright (c) 2025 WSO2 LLC. (https://www.wso2.com). +// +// WSO2 LLC. licenses this file to you under the Apache License, +// Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import ballerina/test; +import ballerina/io; + +const MASKED_LOGGER_CONFIG_FILE = "tests/resources/samples/masked-logger/Config.toml"; + +@test:Config { + groups: ["maskedLogger"] +} +function testMaskedLogger() returns error? { + Process|error execResult = exec(bal_exec_path, {BAL_CONFIG_FILES: MASKED_LOGGER_CONFIG_FILE}, (), "run", string `${temp_dir_path}/masked-logger`); + Process result = check execResult; + int _ = check result.waitForExit(); + int _ = check result.exitCode(); + io:ReadableByteChannel readableResult = result.stderr(); + io:ReadableCharacterChannel sc = new (readableResult, UTF_8); + string outText = check sc.read(100000); + string[] logLines = re `\n`.split(outText.trim()); + test:assertEquals(logLines.length(), 8, INCORRECT_NUMBER_OF_LINES); + test:assertTrue(logLines[5].includes(string `level=INFO module=wso2/masked_logger message="user logged in" userDetails={"name":"John Doe","password":"*****","mail":"joh**************com"}`)); + test:assertTrue(logLines[6].includes(string `level=DEBUG module=wso2/masked_logger message="user details: {\"name\":\"John Doe\",\"password\":\"*****\",\"mail\":\"joh**************com\"}"`)); + test:assertTrue(logLines[7].includes(string `level=ERROR module=wso2/masked_logger message="error occurred" userDetails={"name":"John Doe","password":"*****","mail":"joh**************com"}`)); + check sc.close(); +} diff --git a/native/src/main/java/io/ballerina/stdlib/log/MaskedStringBuilder.java b/native/src/main/java/io/ballerina/stdlib/log/MaskedStringBuilder.java new file mode 100644 index 00000000..4466f235 --- /dev/null +++ b/native/src/main/java/io/ballerina/stdlib/log/MaskedStringBuilder.java @@ -0,0 +1,719 @@ +/* + * Copyright (c) 2025, WSO2 LLC. (http://www.wso2.org). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package io.ballerina.stdlib.log; + +import io.ballerina.runtime.api.Runtime; +import io.ballerina.runtime.api.creators.ErrorCreator; +import io.ballerina.runtime.api.types.Field; +import io.ballerina.runtime.api.types.IntersectionType; +import io.ballerina.runtime.api.types.RecordType; +import io.ballerina.runtime.api.types.ReferenceType; +import io.ballerina.runtime.api.types.Type; +import io.ballerina.runtime.api.types.TypeTags; +import io.ballerina.runtime.api.utils.IdentifierUtils; +import io.ballerina.runtime.api.utils.StringUtils; +import io.ballerina.runtime.api.utils.TypeUtils; +import io.ballerina.runtime.api.values.BArray; +import io.ballerina.runtime.api.values.BFunctionPointer; +import io.ballerina.runtime.api.values.BMap; +import io.ballerina.runtime.api.values.BString; +import io.ballerina.runtime.api.values.BTable; +import io.ballerina.runtime.api.values.BXml; + +import java.util.Collection; +import java.util.HashMap; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.stream.Collectors; + +/** + * High-performance builder for creating masked string representations of Ballerina values. + * Implements AutoCloseable for proper resource management and memory efficiency. + * + * @since 2.14.0 + */ +public class MaskedStringBuilder implements AutoCloseable { + + private static final BString STRATEGY_KEY = StringUtils.fromString("strategy"); + private static final BString REPLACEMENT_KEY = StringUtils.fromString("replacement"); + private static final BString EXCLUDE_VALUE = StringUtils.fromString("EXCLUDE"); + private static final String FIELD_PREFIX = "$field$."; + private static final String LOG_ANNOTATION_PREFIX = "ballerina/log"; + private static final String SENSITIVE_SUFFIX = ":Sensitive"; + + private static final BString CYCLIC_REFERENCE_ERROR = StringUtils.fromString("Cyclic value reference detected " + + "in the record"); + public static final BString MASKED_STRING_BUILDER_HAS_BEEN_CLOSED = StringUtils.fromString("MaskedStringBuilder" + + " has been closed"); + + // Thread-safe LRU cache for field annotations to avoid repeated extraction + private static final LRUCache>> ANNOTATION_CACHE = new LRUCache<>(1000); + + // Pre-computed hex lookup table for efficient Unicode escaping + private static final char[] HEX_CHARS = "0123456789abcdef".toCharArray(); + + // JSON escape character arrays for efficient escaping + private static final char[] QUOTE_ESCAPE = {'\\', '"'}; + private static final char[] BACKSLASH_ESCAPE = {'\\', '\\'}; + private static final char[] NEWLINE_ESCAPE = {'\\', 'n'}; + private static final char[] TAB_ESCAPE = {'\\', 't'}; + private static final char[] CARRIAGE_RETURN_ESCAPE = {'\\', 'r'}; + private static final char[] BACKSPACE_ESCAPE = {'\\', 'b'}; + private static final char[] FORM_FEED_ESCAPE = {'\\', 'f'}; + + // Control character range constants for Unicode escaping + private static final int ASCII_CONTROL_CHAR_LIMIT = 0x20; // Space character (32) + private static final int ASCII_DEL_CHAR = 0x7F; // DEL character (127) + + private final Runtime runtime; + private final IdentityHashMap visitedValues; + private StringBuilder stringBuilder; + private StringBuilder escapeBuffer; + private boolean closed = false; + + // Initial capacity configuration + private static final int DEFAULT_INITIAL_CAPACITY = 256; + private static final int MAX_REUSABLE_CAPACITY = 8192; + private static final int ESCAPE_BUFFER_SIZE = 64; + + public MaskedStringBuilder(Runtime runtime) { + this(runtime, DEFAULT_INITIAL_CAPACITY); + } + + public MaskedStringBuilder(Runtime runtime, int initialCapacity) { + this.runtime = runtime; + this.visitedValues = new IdentityHashMap<>(); + this.stringBuilder = new StringBuilder(initialCapacity < 0 ? DEFAULT_INITIAL_CAPACITY : initialCapacity); + this.escapeBuffer = new StringBuilder(ESCAPE_BUFFER_SIZE); + } + + /** + * Build a masked string representation of the given value. + * + * @param value the value to mask + * @return the masked string representation + */ + public String build(Object value) { + if (this.closed) { + throw ErrorCreator.createError(MASKED_STRING_BUILDER_HAS_BEEN_CLOSED); + } + + try { + this.visitedValues.clear(); + this.stringBuilder.setLength(0); + + String result = buildInternal(value); + + // If the builder grew too large, replace it with a smaller one for future use + if (this.stringBuilder.capacity() > MAX_REUSABLE_CAPACITY) { + this.stringBuilder = new StringBuilder(DEFAULT_INITIAL_CAPACITY); + } + + // Reset escape buffer if it grew too large + if (this.escapeBuffer.capacity() > ESCAPE_BUFFER_SIZE * 4) { + this.escapeBuffer = new StringBuilder(ESCAPE_BUFFER_SIZE); + } + + return result; + } finally { + this.visitedValues.clear(); + } + } + + private String buildInternal(Object value) { + if (value == null) { + return "null"; + } + if (isBasicType(value)) { + return StringUtils.getStringValue(value); + } + + // Use identity-based checking for cycle detection + if (this.visitedValues.put(value, Boolean.TRUE) != null) { + // Panics on cyclic value references + throw ErrorCreator.createError(CYCLIC_REFERENCE_ERROR); + } + + try { + return processValue(value); + } finally { + this.visitedValues.remove(value); + } + } + + private String processValue(Object value) { + // Getting implied type to handle intersection types with readonly + Type type = getEffectiveType(TypeUtils.getType(value)); + + return switch (value) { + // Processing only the structured types, since the basic types does not contain the + // inherent type information. + case BMap mapValue -> processMapValue(mapValue, type); + case BTable tableValue -> processTableValue(tableValue); + case BArray listValue -> processArrayValue(listValue); + default -> StringUtils.getStringValue(value); + }; + } + + private Type getEffectiveType(Type type) { + // For intersection types, get the first constituent type that is not readonly + if (type.getTag() == TypeTags.INTERSECTION_TAG) { + List constituentTypes = ((IntersectionType) type).getConstituentTypes(); + if (constituentTypes.size() == 2) { + type = constituentTypes.get(0).getTag() == TypeTags.READONLY_TAG ? constituentTypes.get(1) : + constituentTypes.get(0); + return getEffectiveType(type); + } + } + + // Record types can be intersection types, so unwrap them to get the actual record type + if (type.getTag() == TypeTags.RECORD_TYPE_TAG) { + Optional intersectionType = ((RecordType) type).getIntersectionType(); + if (intersectionType.isPresent()) { + return getEffectiveType(intersectionType.get()); + } + } + + // Unwrap reference types to get the actual referred type + if (type.getTag() == TypeTags.TYPE_REFERENCED_TYPE_TAG) { + type = ((ReferenceType) type).getReferredType(); + return getEffectiveType(type); + } + return type; + } + + private String processMapValue(BMap mapValue, Type valueType) { + Map fields = Map.of(); + Map> fieldAnnotations = Map.of(); + + if (valueType.getTag() == TypeTags.RECORD_TYPE_TAG) { + RecordType recType = (RecordType) valueType; + fields = recType.getFields(); + // Use cached field annotations for better performance + fieldAnnotations = getCachedFieldAnnotations(recType); + } + + return processRecordValue(mapValue, fieldAnnotations, fields); + } + + private String processRecordValue(BMap mapValue, Map> fieldAnnotations, + Map fields) { + int startPos = this.stringBuilder.length(); + + this.stringBuilder.append('{'); + addRecordFields(mapValue, fields, fieldAnnotations); + this.stringBuilder.append('}'); + + String result = this.stringBuilder.substring(startPos); + this.stringBuilder.setLength(startPos); + return result; + } + + private void addRecordFields(BMap mapValue, Map fields, + Map> fieldAnnotations) { + boolean first = true; + + for (Object key : mapValue.getKeys()) { + if (!(key instanceof BString keyStr)) { + continue; + } + Object fieldValue = mapValue.get(key); + String fieldName = keyStr.getValue(); + first = fields.containsKey(fieldName) ? + addDefinedFieldValue(fieldAnnotations, fieldName, fieldValue, first) : + addDynamicFieldValue(fieldValue, first, fieldName); + } + } + + private boolean addDynamicFieldValue(Object fieldValue, boolean first, String fieldName) { + String fieldStringValue = buildInternal(fieldValue); + if (!first) { + this.stringBuilder.append(','); + } + appendFieldToJson(fieldName, fieldStringValue, false, fieldValue); + return false; + } + + private boolean addDefinedFieldValue(Map> fieldAnnotations, String fieldName, Object fieldValue, + boolean first) { + Optional> annotation = getLogSensitiveDataAnnotation(fieldAnnotations, fieldName); + Optional fieldStringValue = annotation + .map(fieldAnnotation -> getStringValue(fieldAnnotation, fieldValue, runtime)) + .orElseGet(() -> Optional.of(buildInternal(fieldValue))); + + if (fieldStringValue.isPresent()) { + if (!first) { + this.stringBuilder.append(','); + } + appendFieldToJson(fieldName, fieldStringValue.get(), annotation.isPresent(), fieldValue); + first = false; + } + return first; + } + + /** + * Append field to JSON format by writing directly to StringBuilder + * without creating intermediate String objects for better performance. + */ + private void appendFieldToJson(String fieldName, String value, boolean hasAnnotation, Object fieldValue) { + this.stringBuilder.append('"'); + appendEscapedString(fieldName); + this.stringBuilder.append("\":"); + if (hasAnnotation || fieldValue instanceof BString || fieldValue instanceof BXml) { + this.stringBuilder.append('"'); + appendEscapedString(value); + this.stringBuilder.append('"'); + } else { + this.stringBuilder.append(value); + } + } + + /** + * Append escaped string directly to the main StringBuilder. + * This avoids creating intermediate String objects for better performance. + */ + private void appendEscapedString(String input) { + if (input == null) { + this.stringBuilder.append("null"); + return; + } + + if (!needsEscaping(input)) { + this.stringBuilder.append(input); + return; + } + + for (int i = 0; i < input.length(); i++) { + char c = input.charAt(i); + switch (c) { + case '"' -> this.stringBuilder.append(QUOTE_ESCAPE); + case '\\' -> this.stringBuilder.append(BACKSLASH_ESCAPE); + case '\b' -> this.stringBuilder.append(BACKSPACE_ESCAPE); + case '\f' -> this.stringBuilder.append(FORM_FEED_ESCAPE); + case '\n' -> this.stringBuilder.append(NEWLINE_ESCAPE); + case '\r' -> this.stringBuilder.append(CARRIAGE_RETURN_ESCAPE); + case '\t' -> this.stringBuilder.append(TAB_ESCAPE); + default -> { + // Escape ASCII control characters (0x00-0x1F) and DEL character (0x7F) + if (c < ASCII_CONTROL_CHAR_LIMIT || c == ASCII_DEL_CHAR) { + this.stringBuilder.append("\\u00"); + this.stringBuilder.append(HEX_CHARS[(c >>> 4) & 0xF]); + this.stringBuilder.append(HEX_CHARS[c & 0xF]); + } else { + this.stringBuilder.append(c); + } + } + } + } + } + + /** + * Get cached field annotations for better performance. + * Uses LRU cache which automatically handles eviction of least recently used entries. + */ + private Map> getCachedFieldAnnotations(RecordType recordType) { + Map> cached = ANNOTATION_CACHE.get(recordType); + if (cached != null) { + return cached; + } + + Map> annotations = extractFieldAnnotations(recordType); + ANNOTATION_CACHE.put(recordType, annotations); + return annotations; + } + + private String processTableValue(BTable tableValue) { + Collection values = tableValue.values(); + if (values.isEmpty()) { + return "[]"; + } + + int startPos = this.stringBuilder.length(); + this.stringBuilder.append('['); + + boolean first = true; + for (Object row : values) { + if (!first) { + this.stringBuilder.append(','); + } + String elementString = buildInternal(row); + appendValueToArray(elementString, row); + first = false; + } + + this.stringBuilder.append(']'); + + String result = this.stringBuilder.substring(startPos); + this.stringBuilder.setLength(startPos); + return result; + } + + private String processArrayValue(BArray listValue) { + long length = listValue.getLength(); + if (listValue.isEmpty()) { + return "[]"; + } + + int startPos = this.stringBuilder.length(); + this.stringBuilder.append('['); + + // Using traditional for loop instead of for-each loop since BArray giving + // this error: Cannot read the array length because "" is null + for (long i = 0; i < length; i++) { + if (i > 0) { + this.stringBuilder.append(','); + } + Object element = listValue.get(i); + String elementString = buildInternal(element); + appendValueToArray(elementString, element); + } + + this.stringBuilder.append(']'); + + String result = this.stringBuilder.substring(startPos); + this.stringBuilder.setLength(startPos); + return result; + } + + private void appendValueToArray(String value, Object originalValue) { + if (originalValue instanceof BString) { + this.stringBuilder.append('"'); + appendEscapedString(value); + this.stringBuilder.append('"'); + } else { + this.stringBuilder.append(value); + } + } + + /** + * Check if a value is a basic type that doesn't need complex processing. + */ + private static boolean isBasicType(Object value) { + return value == null || TypeUtils.getType(value).getTag() < TypeTags.NULL_TAG; + } + + /** + * Quick check if a string needs JSON escaping. + * This avoids unnecessary StringBuilder allocation for clean strings. + */ + private static boolean needsEscaping(String input) { + for (int i = 0; i < input.length(); i++) { + char c = input.charAt(i); + // Check for quote, backslash, control characters (0x00-0x1F), or DEL character (0x7F) + if (c == '"' || c == '\\' || (c & 0xFFE0) == 0 || c == ASCII_DEL_CHAR) { + return true; + } + } + return false; + } + + /** + * Get the current capacity of the internal StringBuilder. + * Useful for monitoring memory usage. + * + * @return current capacity + */ + public int getCapacity() { + return this.stringBuilder.capacity(); + } + + /** + * Reset the builder state for reuse while keeping the allocated memory. + * This is more efficient than creating a new builder instance. + */ + public void reset() { + if (this.closed) { + throw ErrorCreator.createError(MASKED_STRING_BUILDER_HAS_BEEN_CLOSED); + } + this.visitedValues.clear(); + this.stringBuilder.setLength(0); + this.escapeBuffer.setLength(0); + } + + /** + * Check if the builder has been closed. + * + * @return true if closed, false otherwise + */ + public boolean isClosed() { + return this.closed; + } + + @Override + public void close() { + if (!this.closed) { + this.visitedValues.clear(); + this.stringBuilder = null; + this.escapeBuffer = null; + this.closed = true; + } + } + + /** + * Clear the annotation cache to free memory. + * Should be called periodically in long-running applications. + */ + public static void clearAnnotationCache() { + ANNOTATION_CACHE.clear(); + } + + /** + * Get the size of the annotation cache for monitoring purposes. + */ + public static int getAnnotationCacheSize() { + return ANNOTATION_CACHE.size(); + } + + /** + * Create a new MaskedStringBuilder instance with default settings. + * + * @param runtime the Ballerina runtime + * @return a new MaskedStringBuilder instance + */ + public static MaskedStringBuilder create(Runtime runtime) { + return new MaskedStringBuilder(runtime); + } + + /** + * Create a new MaskedStringBuilder instance with specified initial capacity. + * + * @param runtime the Ballerina runtime + * @param initialCapacity the initial capacity for the internal StringBuilder + * @return a new MaskedStringBuilder instance + */ + public static MaskedStringBuilder create(Runtime runtime, int initialCapacity) { + return new MaskedStringBuilder(runtime, initialCapacity); + } + + static Optional> getLogSensitiveDataAnnotation(Map> fieldAnnotations, + String fieldName) { + // In the value map keys are unescaped, but the annotation keys are escaped + // Moreover runtime does not provide a way to unescape the annotation keys, so we need to escape the field name + BMap fieldAnnotationMap = fieldAnnotations.get(IdentifierUtils.escapeSpecialCharacters(fieldName)); + if (fieldAnnotationMap == null) { + return Optional.empty(); + } + + Object[] keys = fieldAnnotationMap.getKeys(); + + for (Object key : keys) { + if (key instanceof BString bStringKey) { + String keyValue = bStringKey.getValue(); + // No runtime API to get the annotation from the org name, package name and annotation name + // But the annotation key is in the format: "/::" + // This even works when the package is imported using alias since runtime is using the package name + if (keyValue.endsWith(SENSITIVE_SUFFIX) && keyValue.startsWith(LOG_ANNOTATION_PREFIX)) { + Object annotation = fieldAnnotationMap.get(key); + if (annotation instanceof BMap bMapAnnotation) { + return Optional.of(bMapAnnotation); + } + // Found the target annotation type, no need to continue + break; + } + } + } + return Optional.empty(); + } + + static Map> extractFieldAnnotations(RecordType recordType) { + BMap annotations = recordType.getAnnotations(); + if (annotations == null) { + return Map.of(); + } + + return annotations.entrySet().stream() + .filter(entry -> { + String keyValue = entry.getKey().getValue(); + return keyValue.startsWith(FIELD_PREFIX) && entry.getValue() instanceof BMap; + }) + .collect(Collectors.toMap( + entry -> entry.getKey().getValue().substring(FIELD_PREFIX.length()), + entry -> (BMap) entry.getValue(), + (existing, replacement) -> existing + )); + } + + static Optional getStringValue(BMap annotation, Object realValue, Runtime runtime) { + Object strategy = annotation.get(STRATEGY_KEY); + if (strategy instanceof BString strategyStr && EXCLUDE_VALUE.getValue().equals(strategyStr.getValue())) { + return Optional.empty(); + } + if (strategy instanceof BMap replacementMap) { + Object replacement = replacementMap.get(REPLACEMENT_KEY); + if (replacement instanceof BString replacementStr) { + return Optional.of(replacementStr.getValue()); + } + if (replacement instanceof BFunctionPointer replacer) { + Object replacementString = replacer.call(runtime, + StringUtils.fromString(StringUtils.getStringValue(realValue))); + if (replacementString instanceof BString replacementStrVal) { + return Optional.of(replacementStrVal.getValue()); + } + } + } + return Optional.of(StringUtils.getStringValue(realValue)); + } + + /** + * Thread-safe LRU cache implementation using HashMap + Doubly Linked List. + * + * @param the type of keys maintained by this cache + * @param the type of cached values + */ + private static class LRUCache { + private final int maxSize; + private final Map> cache; + private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + private final ReentrantReadWriteLock.ReadLock readLock = lock.readLock(); + private final ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock(); + + // Dummy head and tail nodes for the doubly linked list + private final Node head; + private final Node tail; + + /** + * Node class for the doubly linked list. + * + * @param the type of keys + * @param the type of values + */ + private static class Node { + K key; + V value; + Node prev; + Node next; + + Node() { + // Constructor for dummy nodes + } + + Node(K key, V value) { + this.key = key; + this.value = value; + } + } + + public LRUCache(int maxSize) { + this.maxSize = maxSize; + this.cache = new HashMap<>(); + + // Initialize dummy head and tail nodes + this.head = new Node<>(); + this.tail = new Node<>(); + this.head.next = this.tail; + this.tail.prev = this.head; + } + + public V get(K key) { + writeLock.lock(); + try { + Node node = cache.get(key); + if (node == null) { + return null; + } + + // Move to head (most recently used) + moveToHead(node); + return node.value; + } finally { + writeLock.unlock(); + } + } + + public V put(K key, V value) { + writeLock.lock(); + try { + Node existingNode = cache.get(key); + + if (existingNode != null) { + // Update existing node + V oldValue = existingNode.value; + existingNode.value = value; + moveToHead(existingNode); + return oldValue; + } else { + // Add new node + Node newNode = new Node<>(key, value); + + if (cache.size() >= maxSize) { + // Remove least recently used node (tail.prev) + Node lru = tail.prev; + removeNode(lru); + cache.remove(lru.key); + } + + cache.put(key, newNode); + addToHead(newNode); + return null; + } + } finally { + writeLock.unlock(); + } + } + + public void clear() { + writeLock.lock(); + try { + cache.clear(); + head.next = tail; + tail.prev = head; + } finally { + writeLock.unlock(); + } + } + + public int size() { + readLock.lock(); + try { + return cache.size(); + } finally { + readLock.unlock(); + } + } + + /** + * Add node right after head (most recently used position). + */ + private void addToHead(Node node) { + node.prev = head; + node.next = head.next; + head.next.prev = node; + head.next = node; + } + + /** + * Remove a node from the doubly linked list. + */ + private void removeNode(Node node) { + node.prev.next = node.next; + node.next.prev = node.prev; + } + + /** + * Move a node to head (most recently used position). + */ + private void moveToHead(Node node) { + removeNode(node); + addToHead(node); + } + } +} diff --git a/native/src/main/java/io/ballerina/stdlib/log/Utils.java b/native/src/main/java/io/ballerina/stdlib/log/Utils.java index 0e065e8c..dc69d43f 100644 --- a/native/src/main/java/io/ballerina/stdlib/log/Utils.java +++ b/native/src/main/java/io/ballerina/stdlib/log/Utils.java @@ -18,6 +18,7 @@ package io.ballerina.stdlib.log; +import io.ballerina.runtime.api.Environment; import io.ballerina.runtime.api.creators.ErrorCreator; import io.ballerina.runtime.api.utils.IdentifierUtils; import io.ballerina.runtime.api.utils.StringUtils; @@ -98,4 +99,11 @@ public static BString getCurrentTime() { new SimpleDateFormat(SIMPLE_DATE_FORMAT) .format(new Date())); } + + public static BString toMaskedString(Environment env, Object value) { + // Use try-with-resources for automatic cleanup + try (MaskedStringBuilder builder = MaskedStringBuilder.create(env.getRuntime())) { + return StringUtils.fromString(builder.build(value)); + } + } }