Skip to content

Commit 1f7c8b2

Browse files
authored
Add ability to censor elements in the URL path and host (#21)
- Add ability to censor elements in the URL path and host - Add unit test to confirm feature working - Add CHANGELOG.md and README.md details for path censoring
1 parent fbe20ce commit 1f7c8b2

File tree

10 files changed

+252
-55
lines changed

10 files changed

+252
-55
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# CHANGELOG
22

3+
## Next Release
4+
5+
- [ADDED] Ability to censor parts of a URL path using regex patterns.
6+
37
## v0.4.2 (2022-10-20)
48

59
- Fix a bug where the error data of a bad HTTP request (4xx or 5xx) was not stored as expected in cassettes, causing

README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,13 @@ Now when tests are run, no real HTTP calls will be made. Instead, the HTTP respo
9191

9292
### Censoring
9393

94-
Censor sensitive data in the request and response bodies and headers, such as API keys and auth tokens.
94+
Censor sensitive data in the request and response, such as API keys and auth tokens.
9595

96-
NOTE: Censors can only be applied to JSON request and response bodies. Attempting to apply censors to non-JSON data will throw an exception.
96+
Can censor:
97+
- Request and response headers (via key name)
98+
- Request and response bodies (via key name) (JSON only)
99+
- Request query parameters (via key name)
100+
- Request URL path elements (via regex pattern matching)
97101

98102
**Default**: *Disabled*
99103

@@ -114,12 +118,17 @@ public class Example {
114118
Cassette cassette = new Cassette("path/to/cassettes", "my_cassette");
115119

116120
AdvancedSettings advancedSettings = new AdvancedSettings();
121+
117122
List<String> headersToCensor = new ArrayList<>();
118123
headersToCensor.add("Authorization"); // Hide the Authorization header
119124
advancedSettings.censors = new Censors().censorHeadersByKeys(headersToCensor);
120125
advancedSettings.censors.censorBodyElements(new ArrayList<>() {{
121126
add(new CensorElement("table", true)); // Hide the table element (case-sensitive) in the request and response body
122127
}});
128+
advancedSettings.censors.censorPathElementsByPattern(new ArrayList<>() {{
129+
add(".*\\d{4}.*"); // Hide any path element that contains 4 digits
130+
}});
131+
123132
// or
124133
advancedSettings.censors =
125134
Censors.strict(); // use the built-in strict censoring mode (hides common sensitive data)

src/main/java/com/easypost/easyvcr/CensorElement.java

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,21 @@
22

33
public class CensorElement {
44
/**
5-
* The name of the element to censor.
5+
* The value of the element to censor.
66
*/
7-
private final String name;
7+
protected final String value;
88
/**
9-
* Whether the name must match exactly to trigger a censor.
9+
* Whether the value must match exactly to trigger a censor.
1010
*/
11-
private final boolean caseSensitive;
11+
protected final boolean caseSensitive;
1212

1313
/**
1414
* Constructor.
1515
* @param name The name of the element to censor.
1616
* @param caseSensitive Whether the name must match exactly to trigger a censor.
1717
*/
1818
public CensorElement(String name, boolean caseSensitive) {
19-
this.name = name;
19+
this.value = name;
2020
this.caseSensitive = caseSensitive;
2121
}
2222

@@ -27,9 +27,9 @@ public CensorElement(String name, boolean caseSensitive) {
2727
*/
2828
public boolean matches(String key) {
2929
if (caseSensitive) {
30-
return key.equals(name);
30+
return key.equals(value);
3131
} else {
32-
return key.equalsIgnoreCase(name);
32+
return key.equalsIgnoreCase(value);
3333
}
3434
}
3535
}

src/main/java/com/easypost/easyvcr/Censors.java

Lines changed: 93 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@ public final class Censors {
3636
*/
3737
private final List<CensorElement> queryParamsToCensor;
3838

39+
/**
40+
* The URL path elements to censor.
41+
*/
42+
private final List<RegexCensorElement> pathElementsToCensor;
43+
3944
/**
4045
* Initialize a new instance of the Censors factory, using default censor string.
4146
*/
@@ -52,6 +57,7 @@ public Censors(String censorString) {
5257
this.queryParamsToCensor = new ArrayList<>();
5358
this.bodyElementsToCensor = new ArrayList<>();
5459
this.headersToCensor = new ArrayList<>();
60+
this.pathElementsToCensor = new ArrayList<>();
5561
this.censorText = censorString;
5662
}
5763

@@ -126,7 +132,7 @@ private static List<Object> applyJsonCensors(List<Object> list, String censorTex
126132
* @param dictionary JSON dictionary to process.
127133
* @param censorText Text to use when censoring an element.
128134
* @param elementsToCensor List of elements to find and censor.
129-
* @return Censored JSON dicstionary.
135+
* @return Censored JSON dictionary.
130136
*/
131137
private static Map<String, Object> applyJsonCensors(Map<String, Object> dictionary, String censorText,
132138
List<CensorElement> elementsToCensor) {
@@ -280,45 +286,73 @@ public static Map<String, List<String>> applyHeaderCensors(Map<String, List<Stri
280286
/**
281287
* Censor the appropriate query parameters.
282288
*
283-
* @param url Full URL string to apply censors to.
284-
* @param censorText The string to use to censor sensitive information.
285-
* @param queryParamsToCensor The query parameters to censor.
289+
* @param url Full URL string to apply censors to.
290+
* @param censorText The string to use to censor sensitive information.
291+
* @param queryParamsToCensor The query parameters to censor.
292+
* @param pathElementsToCensor The path elements to censor.
286293
* @return Censored URL string.
287294
*/
288-
public static String applyQueryParameterCensors(String url, String censorText,
289-
List<CensorElement> queryParamsToCensor) {
295+
public static String applyUrlCensors(String url, String censorText,
296+
List<CensorElement> queryParamsToCensor,
297+
List<RegexCensorElement> pathElementsToCensor) {
290298
if (url == null || url.length() == 0) {
291299
// short circuit if url is null
292300
return url;
293301
}
294302

295-
if (queryParamsToCensor.size() == 0) {
303+
if (queryParamsToCensor.size() == 0 && pathElementsToCensor.size() == 0) {
296304
// short circuit if there are no censors to apply
297305
return url;
298306
}
299307

300308
URI uri = URI.create(url);
301-
Map<String, String> queryParameters = Tools.queryParametersToMap(uri);
309+
310+
String path = Utilities.extractPathFromUri(uri);
311+
Map<String, String> queryParameters = Utilities.queryParametersToMap(uri);
312+
313+
String censoredPath;
314+
String censoredQueryString;
315+
316+
if (pathElementsToCensor.size() == 0) {
317+
// don't need to censor path elements
318+
censoredPath = path;
319+
} else {
320+
// censor path elements
321+
String tempPath = path;
322+
for (RegexCensorElement regexCensorElement : pathElementsToCensor) {
323+
tempPath = regexCensorElement.matchAndReplaceAsNeeded(tempPath, censorText);
324+
}
325+
326+
censoredPath = tempPath;
327+
}
328+
302329
if (queryParameters.size() == 0) {
303330
// short circuit if there are no query parameters to censor
304-
return url;
305-
}
331+
censoredQueryString = null;
332+
} else {
333+
if (queryParamsToCensor.size() == 0) {
334+
// don't need to censor query parameters
335+
censoredQueryString = uri.getQuery();
336+
} else {
337+
// censor query parameters
338+
List<String> queryKeys = new ArrayList<>(queryParameters.keySet());
339+
for (String queryKey : queryKeys) {
340+
if (elementShouldBeCensored(queryKey, queryParamsToCensor)) {
341+
queryParameters.put(queryKey, censorText);
342+
}
343+
}
306344

307-
List<String> queryKeys = new ArrayList<>(queryParameters.keySet());
308-
for (String queryKey : queryKeys) {
309-
if (elementShouldBeCensored(queryKey, queryParamsToCensor)) {
310-
queryParameters.put(queryKey, censorText);
345+
List<NameValuePair> censoredQueryParametersList = Tools.mapToQueryParameters(queryParameters);
346+
censoredQueryString = URLEncodedUtils.format(censoredQueryParametersList, StandardCharsets.UTF_8);
311347
}
312348
}
313349

314-
List<NameValuePair> censoredQueryParametersList = Tools.mapToQueryParameters(queryParameters);
315-
String formattedQueryParameters = URLEncodedUtils.format(censoredQueryParametersList, StandardCharsets.UTF_8);
316-
if (formattedQueryParameters.length() == 0) {
317-
// short circuit if there are no query parameters to censor
318-
return url;
350+
String censoredUrl = censoredPath;
351+
if (censoredQueryString != null) {
352+
censoredUrl += "?" + censoredQueryString;
319353
}
320354

321-
return uri.getScheme() + "://" + uri.getHost() + uri.getPath() + "?" + formattedQueryParameters;
355+
return uri.getScheme() + "://" + censoredUrl;
322356
}
323357

324358
/**
@@ -360,7 +394,7 @@ public Censors censorBodyElementsByKeys(List<String> elementKeys) {
360394
* Add a rule to censor specified headers.
361395
* Note: This will censor the header keys in both the request and response.
362396
*
363-
* @param headers List of Headers to censor.
397+
* @param headers List of headers to censor.
364398
* @return This Censors factory.
365399
*/
366400
public Censors censorHeaders(List<CensorElement> headers) {
@@ -397,7 +431,7 @@ public Censors censorHeadersByKeys(List<String> headerKeys) {
397431
/**
398432
* Add a rule to censor specified query parameters.
399433
*
400-
* @param elements List of QueryElements to censor.
434+
* @param elements List of query parameters to censor.
401435
* @return This Censors factory.
402436
*/
403437
public Censors censorQueryParameters(List<CensorElement> elements) {
@@ -429,6 +463,41 @@ public Censors censorQueryParametersByKeys(List<String> parameterKeys) {
429463
return censorQueryParametersByKeys(parameterKeys, false);
430464
}
431465

466+
/**
467+
* Add a rule to censor specified path elements.
468+
*
469+
* @param elements List of path elements to censor.
470+
* @return This Censors factory.
471+
*/
472+
public Censors censorPathElements(List<RegexCensorElement> elements) {
473+
pathElementsToCensor.addAll(elements);
474+
return this;
475+
}
476+
477+
/**
478+
* Add a rule to censor specified path elements by regular expression patterns.
479+
*
480+
* @param patterns Patterns of path elements to censor.
481+
* @param caseSensitive Whether to use case-sensitive pattern matching.
482+
* @return This Censors factory.
483+
*/
484+
public Censors censorPathElementsByPattern(List<String> patterns, boolean caseSensitive) {
485+
for (String pattern : patterns) {
486+
pathElementsToCensor.add(new RegexCensorElement(pattern, caseSensitive));
487+
}
488+
return this;
489+
}
490+
491+
/**
492+
* Add a rule to censor specified path elements by regular expression patterns.
493+
*
494+
* @param patterns Patterns of path elements to censor.
495+
* @return This Censors factory.
496+
*/
497+
public Censors censorPathElementsByPattern(List<String> patterns) {
498+
return censorPathElementsByPattern(patterns, false);
499+
}
500+
432501
/**
433502
* Censor the appropriate body elements.
434503
*
@@ -455,7 +524,7 @@ public Map<String, List<String>> applyHeaderCensors(Map<String, List<String>> he
455524
* @param url Full URL string to apply censors to.
456525
* @return Censored URL string.
457526
*/
458-
public String applyQueryParameterCensors(String url) {
459-
return applyQueryParameterCensors(url, this.censorText, this.queryParamsToCensor);
527+
public String applyUrlCensors(String url) {
528+
return applyUrlCensors(url, this.censorText, this.queryParamsToCensor, this.pathElementsToCensor);
460529
}
461530
}

src/main/java/com/easypost/easyvcr/MatchRules.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,8 @@ public MatchRules byFullUrl(boolean exact) {
142142
} else {
143143
byBaseUrl();
144144
by((received, recorded) -> {
145-
Map<String, String> receivedQuery = Tools.queryParametersToMap(received.getUri());
146-
Map<String, String> recordedQuery = Tools.queryParametersToMap(recorded.getUri());
145+
Map<String, String> receivedQuery = Utilities.queryParametersToMap(received.getUri());
146+
Map<String, String> recordedQuery = Utilities.queryParametersToMap(recorded.getUri());
147147
if (receivedQuery.size() != recordedQuery.size()) {
148148
return false;
149149
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.easypost.easyvcr;
2+
3+
import java.util.regex.Matcher;
4+
import java.util.regex.Pattern;
5+
6+
public class RegexCensorElement extends CensorElement {
7+
/**
8+
* Constructor.
9+
*
10+
* @param pattern The regular expression pattern of the element to censor.
11+
* @param caseSensitive Whether the pattern is case sensitive.
12+
*/
13+
public RegexCensorElement(String pattern, boolean caseSensitive) {
14+
super(pattern, caseSensitive);
15+
}
16+
17+
/**
18+
* Replace the provided value with the provided replacement if the value matches the pattern.
19+
*
20+
* @param value Value to apply the replacement to.
21+
* @param replacement Replacement for a detected matching section.
22+
* @return The replacement if the value matches the pattern, otherwise the original value.
23+
*/
24+
public String matchAndReplaceAsNeeded(String value, String replacement) {
25+
Pattern pattern = Pattern.compile(this.value, caseSensitive ? 0 : Pattern.CASE_INSENSITIVE);
26+
Matcher matcher = pattern.matcher(value);
27+
return matcher.replaceAll(replacement);
28+
}
29+
30+
/**
31+
* Return whether the provided element matches the name, accounting for case sensitivity.
32+
*
33+
* @param key The value to check.
34+
* @return True if the element matches the pattern.
35+
*/
36+
@Override
37+
public boolean matches(String key) {
38+
Pattern pattern = Pattern.compile(this.value, caseSensitive ? 0 : Pattern.CASE_INSENSITIVE);
39+
Matcher matcher = pattern.matcher(key);
40+
41+
// a portion of the key matches the pattern, find() == true, matches() == false (whole key must match)
42+
return matcher.find();
43+
}
44+
}

src/main/java/com/easypost/easyvcr/Utilities.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
package com.easypost.easyvcr;
22

3+
import org.apache.http.NameValuePair;
4+
import org.apache.http.client.utils.URLEncodedUtils;
5+
36
import javax.net.ssl.HttpsURLConnection;
47
import java.net.HttpURLConnection;
8+
import java.net.URI;
9+
import java.nio.charset.StandardCharsets;
10+
import java.util.Collections;
511
import java.util.List;
612
import java.util.Map;
713

@@ -62,4 +68,44 @@ public static String removeJsonElements(String json, List<CensorElement> element
6268

6369
return Censors.censorJsonData(json, "FILTERED", elements);
6470
}
71+
72+
/**
73+
* Convert a URI's query parameters to a Map.
74+
*
75+
* @param uri The URI.
76+
* @return The Map of query parameters.
77+
*/
78+
public static Map<String, String> queryParametersToMap(URI uri) {
79+
List<NameValuePair> receivedQueryDict = URLEncodedUtils.parse(uri, StandardCharsets.UTF_8);
80+
if (receivedQueryDict == null || receivedQueryDict.size() == 0) {
81+
return Collections.emptyMap();
82+
}
83+
Map<String, String> queryDict = new java.util.Hashtable<>();
84+
for (NameValuePair pair : receivedQueryDict) {
85+
queryDict.put(pair.getName(), pair.getValue());
86+
}
87+
return queryDict;
88+
}
89+
90+
/**
91+
* Extract the path from a URI.
92+
*
93+
* @param uri The URI to extract the path from.
94+
* @return The path.
95+
*/
96+
public static String extractPathFromUri(URI uri) {
97+
String uriString = uri.toString();
98+
99+
// strip the query parameters
100+
uriString = uriString.replace(uri.getQuery(), "");
101+
102+
if (uriString.endsWith("?")) {
103+
uriString = uriString.substring(0, uriString.length() - 1);
104+
}
105+
106+
// strip the scheme
107+
uriString = uriString.replace(uri.getScheme() + "://", "");
108+
109+
return uriString;
110+
}
65111
}

0 commit comments

Comments
 (0)