Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
import fiftyone.pipeline.engines.flowelements.AspectEngineBase;
import fiftyone.pipeline.engines.services.HttpClient;
import fiftyone.pipeline.exceptions.AggregateException;
import fiftyone.pipeline.util.LicenseKeyMask;
import org.json.JSONArray;
import org.json.JSONObject;
import org.slf4j.Logger;
Expand Down Expand Up @@ -425,6 +426,19 @@ private static URL toUrl(String spec) throws MalformedURLException {
}
}

/**
* The license key sent with each request, and each key in it when it
* holds several separated by '|', to mask in messages.
*/
private List<String> getLicenseKeys() {
List<String> keys = new ArrayList<>();
if (licenseKey != null && licenseKey.isEmpty() == false) {
keys.add(licenseKey);
keys.addAll(Arrays.asList(licenseKey.split(Pattern.quote("|"))));
}
return keys;
}

private void getCloudProperties() throws CloudRequestException, AggregateException, PropertyNotLoadedException {
try {
String jsonResult;
Expand Down Expand Up @@ -544,6 +558,9 @@ else if (messages.size() == 0 && code != 200)
messages.add(message);
}

// The cloud's errors can name the license key it was sent.
messages.replaceAll(m -> LicenseKeyMask.maskIn(m, getLicenseKeys()));

Map<String, List<String>> headers = null;
if (messages.size() > 0){
headers = connection.getHeaderFields();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.ArgumentCaptor;
import org.mockito.ArgumentMatchers;
import org.slf4j.ILoggerFactory;
import org.slf4j.Logger;

import java.io.IOException;
import java.net.MalformedURLException;
Expand Down Expand Up @@ -101,6 +103,66 @@ public void Process() throws Exception {
}
}

/**
* The cloud answers a refused license key with an error that names it.
* The exception the engine raises, and every log entry, show the key
* only by its last 4 characters.
*/
@Test
public void Process_LicenseKeyInCloudError_Masked() throws Exception {
final String licenseKey = "TESTKEYNOTREALABCDEFGHIJ1234";
final String masked = "************************1234";
jsonResponse = "{\"errors\":[\"Invalid license key(s) supplied: " +
licenseKey + "\"]}";
configureMockedClient();
Logger logger = mock(Logger.class);
when(logger.isDebugEnabled()).thenReturn(true);
when(logger.isInfoEnabled()).thenReturn(true);
when(logger.isWarnEnabled()).thenReturn(true);
when(logger.isErrorEnabled()).thenReturn(true);
ILoggerFactory capturingLoggerFactory = mock(ILoggerFactory.class);
when(capturingLoggerFactory.getLogger(anyString())).thenReturn(logger);

CloudRequestEngine engine = new CloudRequestEngineBuilder(
capturingLoggerFactory, httpClient)
.setResourceKey("resource_key")
.setLicenseKey(licenseKey)
.build();

try (Pipeline pipeline = new PipelineBuilder(capturingLoggerFactory)
.setSuppressProcessException(true)
.addFlowElement(engine)
.build();
FlowData data = pipeline.createFlowData()) {
data.addEvidence("query.User-Agent", "iPhone");
data.process();

assertFalse(data.getErrors().isEmpty());
StringBuilder reported = new StringBuilder();
for (FlowError error : data.getErrors()) {
reported.append(printed(error.getThrowable()));
}
for (org.mockito.invocation.Invocation invocation :
mockingDetails(logger).getInvocations()) {
for (Object argument : invocation.getArguments()) {
reported.append(argument instanceof Throwable ?
printed((Throwable) argument) :
String.valueOf(argument));
}
}
assertFalse(reported.toString().contains(licenseKey),
reported.toString());
assertTrue(reported.toString().contains(masked),
reported.toString());
}
}

private static String printed(Throwable throwable) {
java.io.StringWriter text = new java.io.StringWriter();
throwable.printStackTrace(new java.io.PrintWriter(text));
return text.toString();
}

/**
* Test errors thrown by cloud request engine are added to flow.errors when SuppressProcessExceptions == true
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
/* *********************************************************************
* This Original Work is copyright of 51 Degrees Mobile Experts Limited.
* Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House,
* Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU.
*
* This Original Work is licensed under the European Union Public Licence
* (EUPL) v.1.2 and is subject to its terms as set out below.
*
* If a copy of the EUPL was not distributed with this file, You can obtain
* one at https://opensource.org/licenses/EUPL-1.2.
*
* The 'Compatible Licences' set out in the Appendix to the EUPL (as may be
* amended by the European Commission) shall be deemed incompatible for
* the purposes of the Work and the provisions of the compatibility
* clause in Article 5 of the EUPL shall not apply.
*
* If using the Work as, or as part of, a network application, by
* including the attribution notice(s) required under Article 5 of the EUPL
* in the end user terms of the application under an appropriate heading,
* such notice(s) shall fulfill the requirements of that article.
* ********************************************************************* */

package fiftyone.pipeline.util;

import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;

/**
* Masks license keys in text written to a log entry or an error message. A
* masked key shows only its last 4 characters, with every character before
* them replaced by '*', so a reader can tell two keys apart but cannot use
* either. A key of 4 characters or fewer is replaced in full, because its
* last 4 characters would be the whole key.
* @see <a href="https://github.com/51Degrees/specifications/blob/rc/pipeline-specification/features/logging.md#license-keys">Specification</a>
*/
public final class LicenseKeyMask {

/**
* How many characters at the end of a key stay visible.
*/
public static final int VISIBLE_CHARACTERS = 4;

/**
* The character that replaces each hidden character of a key.
*/
public static final char MASK_CHARACTER = '*';

private LicenseKeyMask() {
}

/**
* The key with every character before its last 4 replaced.
* @param key a license key, or null
* @return the masked key, or null if the key is null
*/
public static String mask(String key) {
if (key == null) {
return null;
}
int hidden = key.length() > VISIBLE_CHARACTERS ?
key.length() - VISIBLE_CHARACTERS :
key.length();
StringBuilder masked = new StringBuilder(key.length());
for (int i = 0; i < hidden; i++) {
masked.append(MASK_CHARACTER);
}
return masked.append(key, hidden, key.length()).toString();
}

/**
* The text with every occurrence of each key masked, both as the key is
* written and as it is encoded in a URL, with upper or lower case
* hexadecimal digits. An encoded key is replaced by the encoded form of
* its masked plain form, so it shows the same last 4 characters. Every
* occurrence of a key's text is masked, so a key of only a few
* characters also masks those characters wherever else they appear.
* @param text the text, such as a URL or an error message, or null
* @param keys the license keys, or null
* @return the masked text, or null if the text is null
*/
public static String maskIn(String text, Iterable<String> keys) {
if (text == null || keys == null) {
return text;
}
String result = text;
for (String key : longestFirst(keys)) {
String masked = mask(key);
result = result.replace(key, masked);
String encoded = urlEncode(key);
if (encoded.equals(key) == false) {
String maskedEncoded = urlEncode(masked);
result = result.replace(encoded, maskedEncoded);
result = result.replace(
lowerCaseEscapes(encoded),
lowerCaseEscapes(maskedEncoded));
}
}
return result;
}

/**
* The throwable to log or report in place of the one given, so that no
* message in it, its causes or its suppressed throwables holds a key.
* When none does, the throwable given is returned. Otherwise a copy is
* returned whose messages are masked, each starting with the name of the
* class it stands for, and whose stack traces are the originals.
* @param throwable the throwable, or null
* @param keys the license keys, or null
* @return a throwable that holds no key
*/
public static Throwable maskIn(Throwable throwable, Iterable<String> keys) {
if (throwable == null ||
keys == null ||
holdsKey(throwable, keys, new IdentityHashMap<Throwable, Boolean>())
== false) {
return throwable;
}
return copy(throwable, keys, new IdentityHashMap<Throwable, Throwable>());
}

private static boolean holdsKey(
Throwable throwable,
Iterable<String> keys,
Map<Throwable, Boolean> seen) {
if (throwable == null || seen.put(throwable, Boolean.TRUE) != null) {
return false;
}
String message = throwable.getMessage();
if (message != null && message.equals(maskIn(message, keys)) == false) {
return true;
}
if (holdsKey(throwable.getCause(), keys, seen)) {
return true;
}
for (Throwable suppressed : throwable.getSuppressed()) {
if (holdsKey(suppressed, keys, seen)) {
return true;
}
}
return false;
}

private static Throwable copy(
Throwable throwable,
Iterable<String> keys,
Map<Throwable, Throwable> copies) {
Throwable existing = copies.get(throwable);
if (existing != null) {
return existing;
}
String message = maskIn(throwable.getMessage(), keys);
MaskedThrowable masked = new MaskedThrowable(
throwable.getClass().getName() +
(message == null ? "" : ": " + message));
copies.put(throwable, masked);
masked.setStackTrace(throwable.getStackTrace());
Throwable cause = throwable.getCause();
if (cause != null) {
Throwable maskedCause = copy(cause, keys, copies);
if (maskedCause != masked) {
masked.initCause(maskedCause);
}
}
for (Throwable suppressed : throwable.getSuppressed()) {
Throwable maskedSuppressed = copy(suppressed, keys, copies);
if (maskedSuppressed != masked) {
masked.addSuppressed(maskedSuppressed);
}
}
return masked;
}

/**
* The keys that are not null or empty, longest first, so that a key
* that holds another is masked whole.
*/
private static List<String> longestFirst(Iterable<String> keys) {
List<String> result = new ArrayList<>();
for (String key : keys) {
if (key != null && key.isEmpty() == false) {
result.add(key);
}
}
Collections.sort(result, (a, b) -> b.length() - a.length());
return result;
}

/**
* The text with the two hexadecimal digits after each '%' in lower
* case.
*/
private static String lowerCaseEscapes(String text) {
StringBuilder result = new StringBuilder(text);
for (int i = text.indexOf('%');
i >= 0 && i + 2 < text.length();
i = text.indexOf('%', i + 1)) {
result.setCharAt(i + 1, Character.toLowerCase(text.charAt(i + 1)));
result.setCharAt(i + 2, Character.toLowerCase(text.charAt(i + 2)));
}
return result.toString();
}

private static String urlEncode(String key) {
try {
return URLEncoder.encode(key, "UTF-8");
} catch (UnsupportedEncodingException e) {
// Every Java platform supports UTF-8.
throw new IllegalStateException(e);
}
}

/**
* Stands for a throwable whose message held a license key. Its message
* starts with the name of the class it stands for, and it prints as
* that message alone.
*/
private static final class MaskedThrowable extends Exception {
private static final long serialVersionUID = 1L;

MaskedThrowable(String message) {
super(message);
}

@Override
public String toString() {
return getMessage();
}
}
}
Loading
Loading