Skip to content

Commit

Permalink
Created Fuzzing against Jackson and fixed multiple small bugs found w…
Browse files Browse the repository at this point in the history
…ith ti (#28)

* Fixed ParseAndMaskUtil to support (nested) objects and arrays just like the actual masker

* Moved target key preprocessing to masker instead of in the config

* Refactor ParseAndMaskUtil to take JsonMaskingConfig as parameter

* Add target key mode convenience methods to JsonMaskingConfig

* Implemented fuzzing against Jackson for ALLOW mode

* Fix nested object masking in allow mode

* Fix allow mode nested array bug and double step over
  • Loading branch information
Breus authored Dec 29, 2023
1 parent 473d7fd commit f1c528d
Show file tree
Hide file tree
Showing 8 changed files with 424 additions and 180 deletions.
25 changes: 14 additions & 11 deletions src/jmh/java/dev/blaauwendraad/masker/json/BaselineBenchmark.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
package dev.blaauwendraad.masker.json;

import com.fasterxml.jackson.databind.ObjectMapper;
import dev.blaauwendraad.masker.json.config.JsonMaskingConfig;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Param;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.Warmup;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
Expand All @@ -22,17 +30,16 @@ public class BaselineBenchmark {

@org.openjdk.jmh.annotations.State(Scope.Thread)
public static class State {
@Param({"1kb", "128kb", "2mb"})
@Param({ "1kb", "128kb", "2mb" })
String jsonSize;
@Param({"unicode"})
@Param({ "unicode" })
String characters;
@Param({"0.01"})
@Param({ "0.01" })
double maskedKeyProbability;

private Set<String> targetKeys;
private String jsonString;
private byte[] jsonBytes;
private ObjectMapper objectMapper;
private List<Pattern> regexList;

@Setup
Expand All @@ -41,8 +48,6 @@ public synchronized void setup() {
jsonString = BenchmarkUtils.randomJson(targetKeys, jsonSize, characters, maskedKeyProbability);
jsonBytes = jsonString.getBytes(StandardCharsets.UTF_8);

objectMapper = new ObjectMapper();

regexList = targetKeys.stream()
// will only match primitive values, not objects or arrays, but it's good to show the difference
.map(key -> Pattern.compile("(\"" + key + "\"\\s*:\\s*)(\"?[^\"]*\"?)", Pattern.CASE_INSENSITIVE))
Expand Down Expand Up @@ -80,9 +85,7 @@ public String regexReplace(State state) {
public String jacksonParseAndMask(State state) throws IOException {
return ParseAndMaskUtil.mask(
state.jsonString,
state.targetKeys,
JsonMaskingConfig.TargetKeyMode.MASK,
state.objectMapper
JsonMaskingConfig.getDefault(state.targetKeys)
).toString();
}
}
46 changes: 30 additions & 16 deletions src/main/java/dev/blaauwendraad/masker/json/KeyContainsMasker.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,22 @@
* {@link JsonMasker} implementation.
*/
public final class KeyContainsMasker implements JsonMasker {
/*
/**
* We are looking for targeted JSON keys with a maskable JSON value, so the closing quote can appear at minimum 3
* characters till the end of the JSON in the following minimal case: '{"":1}'
* characters till the end of the JSON in the following minimal case: '{"":1}'
*/
private static final int MIN_OFFSET_JSON_KEY_QUOTE = 3;
/*
/**
* Minimum JSON for which masking could be required is: {"":""}, so minimum length at least 7 bytes.
*/
private static final int MIN_MASKABLE_JSON_LENGTH = 7;

/**
* Look-up trie containing the target keys.
*/
private final ByteTrie targetKeysTrie;
private final boolean allowMode;
/**
* The masking configuration for the JSON masking process.
*/
private final JsonMaskingConfig maskingConfig;

/**
Expand All @@ -34,7 +38,6 @@ public final class KeyContainsMasker implements JsonMasker {
* @param maskingConfig the masking configurations for the created masker
*/
public KeyContainsMasker(JsonMaskingConfig maskingConfig) {
this.allowMode = maskingConfig.getTargetKeyMode() == JsonMaskingConfig.TargetKeyMode.ALLOW;
this.maskingConfig = maskingConfig;
this.targetKeysTrie = new ByteTrie(!maskingConfig.caseSensitiveTargetKeys());
for (String key : maskingConfig.getTargetKeys()) {
Expand Down Expand Up @@ -121,11 +124,11 @@ public byte[] mask(byte[] input) {
openingQuoteIndex + 1, // plus one for the opening quote
keyLength
);
if (allowMode && keyMatched) {
if (maskingConfig.isInAllowMode() && keyMatched) {
skipAllValues(maskingState); // the value belongs to a JSON key which is explicitly allowed, so skip it
continue;
}
if (!allowMode && !keyMatched) {
if (maskingConfig.isInMaskMode() && !keyMatched) {
continue mainLoop; // The found JSON key is not a target key, so continue looking from where we left of.
}

Expand Down Expand Up @@ -314,14 +317,14 @@ private void maskArrayValueInPlace(MaskingState maskingState) {
/**
* Masks all values (depending on the {@link JsonMaskingConfig} in the object.
*
* @param maskingState the current masking state
* @param maskingState the current masking state
*/
private void maskObjectValueInPlace(MaskingState maskingState) {
maskingState.incrementCurrentIndex(); // step over opening curly bracket
skipWhitespaceCharacters(maskingState);
while (!AsciiCharacter.isCurlyBracketClose(maskingState.byteAtCurrentIndex())) {
boolean valueMustBeMasked = true;
if (allowMode) {
if (maskingConfig.isInAllowMode()) {
// In case target keys should be considered as allow list, we need to NOT mask certain keys
int openingQuoteIndex = maskingState.currentIndex();
maskingState.incrementCurrentIndex(); // step over the JSON key opening quote
Expand Down Expand Up @@ -443,26 +446,37 @@ private static void skipAllValues(MaskingState maskingState) {
}
} else if (AsciiCharacter.isCurlyBracketOpen(maskingState.byteAtCurrentIndex())) { // object
maskingState.incrementCurrentIndex(); // step over opening curly bracket
// We need to specifically skip strings to not consider curly brackets which are part of a string
while (!AsciiCharacter.isCurlyBracketClose(maskingState.byteAtCurrentIndex())) {
int objectDepth = 1;
while (objectDepth > 0) {
// We need to specifically skip strings to not consider curly brackets which are part of a string
if (currentByteIsUnescapedDoubleQuote(maskingState)) {
// this makes sure that we skip curly brackets (open and close) which are part of strings
skipStringValue(maskingState);
} else {
if (AsciiCharacter.isCurlyBracketOpen(maskingState.byteAtCurrentIndex())) {
objectDepth++;
} else if (AsciiCharacter.isCurlyBracketClose(maskingState.byteAtCurrentIndex())) {
objectDepth--;
}
maskingState.incrementCurrentIndex();
}
}
maskingState.incrementCurrentIndex(); // step over closing curly bracket
} else if (AsciiCharacter.isSquareBracketOpen(maskingState.byteAtCurrentIndex())) { // array
maskingState.incrementCurrentIndex(); // step over opening square bracket
// We need to specifically skip strings to not consider square brackets which are part of a string
while (!AsciiCharacter.isSquareBracketClose(maskingState.byteAtCurrentIndex())) {
int arrayDepth = 1;
while (arrayDepth > 0) {
// We need to specifically skip strings to not consider square brackets which are part of a string
if (currentByteIsUnescapedDoubleQuote(maskingState)) {
skipStringValue(maskingState);
} else {
if (AsciiCharacter.isSquareBracketOpen(maskingState.byteAtCurrentIndex())) {
arrayDepth++;
} else if (AsciiCharacter.isSquareBracketClose(maskingState.byteAtCurrentIndex())) {
arrayDepth--;
}
maskingState.incrementCurrentIndex();
}
}
maskingState.incrementCurrentIndex(); // step over closing square bracket
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package dev.blaauwendraad.masker.json.config;

import java.util.Set;
import java.util.stream.Collectors;

/**
* Contains the JSON masker configurations.
Expand Down Expand Up @@ -38,15 +37,11 @@ public class JsonMaskingConfig {
algorithmType = JsonMaskerAlgorithmType.KEYS_CONTAIN;
obfuscationLength = builder.obfuscationLength;
targetKeyMode = builder.targetKeyMode;
Set<String> targets = builder.targets;
if (targetKeyMode == TargetKeyMode.MASK && targets.isEmpty()) {
targetKeys = builder.targets;
if (targetKeyMode == TargetKeyMode.MASK && targetKeys.isEmpty()) {
throw new IllegalArgumentException("Target keys set in mask mode must contain at least a single target key");
}
caseSensitiveTargetKeys = builder.caseSensitiveTargetKeys;
if (!caseSensitiveTargetKeys) {
targets = targets.stream().map(String::toLowerCase).collect(Collectors.toSet());
}
targetKeys = targets;
maskNumericValuesWith = builder.maskNumberValuesWith;
if (builder.maskNumberValuesWith == 0) {
if (builder.obfuscationLength < 0 || builder.obfuscationLength > 1) {
Expand Down Expand Up @@ -228,6 +223,26 @@ public enum TargetKeyMode {
MASK
}

/**
* Checks if the target key mode is set to "ALLOW". If the mode is set to "ALLOW", it means that the target keys are
* interpreted as the only JSON keys for which the corresponding property is allowed (should not be masked).
*
* @return true if the target key mode is set to "ALLOW", false otherwise
*/
public boolean isInAllowMode() {
return targetKeyMode == TargetKeyMode.ALLOW;
}

/**
* Checks if the target key mode is set to "MASK". If the mode is set to "MASK", it means that the properties
* corresponding to the target keys should be masked.
*
* @return true if the current target key mode is in "MASK" mode, false otherwise
*/
public boolean isInMaskMode() {
return targetKeyMode == TargetKeyMode.MASK;
}

@Override
public String toString() {
return "JsonMaskingConfig{" +
Expand Down
92 changes: 28 additions & 64 deletions src/test/java/dev/blaauwendraad/masker/json/FuzzingTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,87 +3,51 @@
import com.fasterxml.jackson.databind.JsonNode;
import dev.blaauwendraad.masker.json.config.JsonMaskingConfig;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.params.provider.MethodSource;
import randomgen.json.RandomJsonGenerator;
import randomgen.json.RandomJsonGeneratorConfig;

import javax.annotation.Nonnull;
import java.time.Duration;
import java.time.Instant;
import java.util.Set;
import java.util.stream.Stream;

//TODO
@Disabled("Have to implement object and array masking first in the Jackson masker")
final class FuzzingTest {
private static final int SECONDS_FOR_EACH_TEST_TO_RUN = 10;
private static final Set<String> DEFAULT_TARGET_KEYS = Set.of("targetKey1", "targetKey2", "targetKey3");
private static final int SECONDS_FOR_EACH_TEST_TO_RUN = 2;

@ValueSource(ints = { SECONDS_FOR_EACH_TEST_TO_RUN })
// duration in seconds the tests runs for
void fuzzing_NoArrayNoObjectValueMasking(int secondsToRunTest) {
long startTime = System.currentTimeMillis();
@ParameterizedTest
@MethodSource("jsonMaskingConfigs")
void fuzzingAgainstParseAndMaskUsingJackson(JsonMaskingConfig jsonMaskingConfig) {
Instant startTime = Instant.now();
int randomTestExecuted = 0;
while (System.currentTimeMillis() < startTime + 10 * 1000) {
Set<String> targetKeys = Set.of("targetKey1", "targetKey2");
JsonMasker keyContainsMasker = new KeyContainsMasker(JsonMaskingConfig.custom(
targetKeys,
JsonMaskingConfig.TargetKeyMode.MASK
).build());
RandomJsonGenerator randomJsonGenerator =
new RandomJsonGenerator(RandomJsonGeneratorConfig.builder().createConfig());
RandomJsonGenerator randomJsonGenerator = new RandomJsonGenerator(RandomJsonGeneratorConfig.builder()
.setTargetKeys(jsonMaskingConfig.getTargetKeys())
.createConfig());
JsonMasker masker = JsonMasker.getMasker(jsonMaskingConfig);
while (Duration.between(startTime, Instant.now()).getSeconds() < SECONDS_FOR_EACH_TEST_TO_RUN) {
JsonNode randomJsonNode = randomJsonGenerator.createRandomJsonNode();
String randomJsonNodeString = randomJsonNode.toPrettyString();
String keyContainsOutput = keyContainsMasker.mask(randomJsonNodeString);
String jacksonMaskingOutput = ParseAndMaskUtil.mask(
randomJsonNode,
targetKeys,
JsonMaskingConfig.TargetKeyMode.MASK
).toPrettyString();
Assertions.assertEquals(
jacksonMaskingOutput,
keyContainsOutput,
"Failed for input: " + randomJsonNodeString
String keyContainsOutput = masker.mask(randomJsonNodeString);
String jacksonMaskingOutput = ParseAndMaskUtil.mask(randomJsonNode, jsonMaskingConfig).toPrettyString();
Assertions.assertEquals(jacksonMaskingOutput,
keyContainsOutput,
"Failed for input: " + randomJsonNodeString
);
randomTestExecuted++;
}
System.out.printf(
"Executed %d randomly generated test scenarios in %d seconds%n",
randomTestExecuted,
secondsToRunTest
System.out.printf("Executed %d randomly generated test scenarios in %d seconds%n",
randomTestExecuted,
SECONDS_FOR_EACH_TEST_TO_RUN
);
}

@ParameterizedTest
@ValueSource(ints = { SECONDS_FOR_EACH_TEST_TO_RUN })
// duration in seconds the tests runs for
void fuzzing_AllowKeys_NoObjectArrayValuesMasking(int secondsToRunTest) {
long startTime = System.currentTimeMillis();
int randomTestExecuted = 0;
while (System.currentTimeMillis() < startTime + 10 * 1000) {
Set<String> targetKeys = Set.of("targetKey1", "targetKey2");
JsonMasker keyContainsMasker = new KeyContainsMasker(JsonMaskingConfig.custom(
targetKeys,
JsonMaskingConfig.TargetKeyMode.ALLOW
).build());
RandomJsonGenerator randomJsonGenerator =
new RandomJsonGenerator(RandomJsonGeneratorConfig.builder().createConfig());
JsonNode randomJsonNode = randomJsonGenerator.createRandomJsonNode();
String randomJsonNodeString = randomJsonNode.toPrettyString();
String keyContainsOutput = keyContainsMasker.mask(randomJsonNodeString);
String jacksonMaskingOutput = ParseAndMaskUtil.mask(
randomJsonNode,
targetKeys,
JsonMaskingConfig.TargetKeyMode.ALLOW
).toPrettyString();
Assertions.assertEquals(
jacksonMaskingOutput,
keyContainsOutput,
"Failed for input: " + randomJsonNodeString
);
randomTestExecuted++;
}
System.out.printf(
"Executed %d randomly generated test scenarios in %d seconds%n",
randomTestExecuted,
secondsToRunTest
@Nonnull
private static Stream<JsonMaskingConfig> jsonMaskingConfigs() {
return Stream.of(JsonMaskingConfig.getDefault(DEFAULT_TARGET_KEYS),
JsonMaskingConfig.custom(DEFAULT_TARGET_KEYS, JsonMaskingConfig.TargetKeyMode.ALLOW).build()
);
}
}
Loading

0 comments on commit f1c528d

Please sign in to comment.