Skip to content

Show a license key only by its last 4 characters in logs and error messages - #85

Draft
jwrosewell wants to merge 6 commits into
rcfrom
fix/mask-license-keys-in-logs
Draft

jwrosewell wants to merge 6 commits into
rcfrom
fix/mask-license-keys-in-logs

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Closes #10. Follows the License keys section of the logging specification, which 51Degrees/specifications#55 added and #123 reworded, both now on rc.

The problem

A license key is a paid credential, and log entries and error messages travel further than the server, to log services, to support tickets and, through the JSON builder's errors list, to the browser. On rc at 7299688 three places wrote license keys in full.

  1. The data update service. FiftyOneUrlFormatter puts the license keys in the query of the update URL. DataUpdateServiceDefault.downloadFile logged that URL at debug level for every download and for a 304, at error level for no response, a 429, a 403 and any other failing status, and logged any exception with its stack trace. The exception an HTTP library raises often names the URL it requested, for example Server returned HTTP response code: 403 for URL: ....
  2. The cloud request engine. The cloud answers a refused license key with an error that names the key. CloudRequestEngineDefault.validateResponse put the cloud's error text, or the whole response for a failing status, in the message of the CloudRequestException it raised.
  3. The 51Did client. DidClient.readRedeem put the first 200 characters of the body in the message of a redeem error, and the errors list of a 400 answer in the message of the IllegalArgumentException it raised. The redeem request is the one that sends the licence key.

A search of every logger. call and exception message in pipeline, device-detection, ip-intelligence, location and examples for license key values and for URLs or response bodies that can hold one found no other place.

What the specification says

The License keys section of pipeline-specification/features/logging.md, on rc at 5a25065, says the following.

A license key MUST NOT appear in full in a log entry or in an error message, at any log level. Where a message names a license key it MUST show only the key's last 4 characters, with each character before them replaced by one *, so that a reader can tell two keys apart but cannot use either. A key of 4 characters or fewer MUST be replaced entirely, by one * for each character. Every package masks the same way, so ABCDEFGH is written ****EFGH in every language.

Some URLs carry a license key in their query, such as the URL a data update request is sent to. A message that names such a URL MUST name it with the key masked in the same way. The same applies to the message of an error raised by an HTTP library, which often includes the URL it requested.

There MUST be a test that a failed request carrying a license key produces log entries and error messages that hold only the masked form of the key.

#55 also added to the logging section of data-updates.md that the <url> in its messages "is the URL with each license key masked to its last 4 characters".

What changed

  1. One helper. fiftyone.pipeline.util.LicenseKeyMask in pipeline.core.
    • mask(key) replaces every character before the last 4 with *, so TESTKEYNOTREALABCDEFGHIJ1234 becomes ************************1234. A key of 4 characters or fewer becomes all *.
    • maskIn(text, keys) masks each key wherever the text holds it, as written and as encoded in a URL, longest key first.
    • maskIn(throwable, keys) gives the throwable to log in place of one whose message, causes or suppressed throwables hold a key. Each message in the copy starts with the name of the class it stands for, and the stack traces are the originals. A throwable that holds no key is returned as it is, so logs that never held a key do not change.
  2. The data update service. downloadFile names the URL in every message with each configured key masked, and logs the exception through maskIn. The request itself still uses the unmasked URL.
  3. The cloud request engine. validateResponse masks the license key in every message before it raises the exception, and so does each key in it when the value holds several separated by | (getLicenseKeys).
  4. The 51Did client. readRedeem masks the licence key in both messages, before the body is cut to 200 characters, so a key cannot be left half shown. DidHttpException.getBody() still returns the body as the service sent it. The 51Did package depends on nothing in the Pipeline but org.json, so it cannot use LicenseKeyMask without bringing pipeline.core and its dependencies to every 51Did user. It masks with a private method of eight lines that gives the same result, and its tests pin the same masked value as the helper's tests.

Resource keys are left as they are, because the section covers license keys only.

For the release notes

  • A license key no longer appears in full in any log entry or error message. Where a message names one, such as the data update URL in the data update service's log, or the cloud's refusal of a license key, it shows only the key's last 4 characters, with every character before them replaced by *.
  • fiftyone.pipeline.util.LicenseKeyMask masks license keys in text and in throwables, for code of your own that logs them.

How to verify

No test calls a live service.

mvn -pl pipeline/pipeline.core,pipeline/pipeline.engines,pipeline/pipeline.cloudrequestengine,pipeline/pipeline.did -am test -Dtest='LicenseKeyMaskTests,DataUpdateServiceLicenseKeyTests,DataUpdateServiceTests,CloudRequestEngineTests,DidClientTests' -Dsurefire.failIfNoSpecifiedTests=false

DidClientTests needs the pipeline/owid-java submodule checked out, as the 51Did package compiles its source.

Class Test methods on rc On this branch
LicenseKeyMaskTests (pipeline.core) 0 7
DataUpdateServiceLicenseKeyTests (pipeline.engines) 0 6
CloudRequestEngineTests (pipeline.cloudrequestengine) 18 19
DidClientTests (pipeline.did) 83 86

CloudRequestEngineTests has four parameterised methods, so a run reports more cases than methods.

  • d989ba0 adds the helper and LicenseKeyMaskTests, which pass.
  • 382fa6c adds the tests of the three places, on their code from rc. Ten are written to fail there: the six in DataUpdateServiceLicenseKeyTests (Forbidden, TooManyRequests, OtherStatus, NotModified, NoResponse and RequestException, each _LogsMaskedKeys), CloudRequestEngineTests.Process_LicenseKeyInCloudError_Masked, and in DidClientTests redeem_RefusedLicenceIsMaskedInTheMessage, redeem_LicenceInErrorsIsMaskedInTheMessage and redeem_ShortLicenceIsMaskedInFull. The data update tests capture everything passed to a mocked logger, at every level, with each throwable's printed stack trace.
  • cb0b80e, this branch's head, masks the keys in the three places.

Other drafts that touch the same lines

#79 rewrites the logging of DataUpdateServiceDefault.downloadFile. It names the URL unmasked in its debug line, in the "Checking for update", "Downloaded new data" and "No data newer" info lines, and through connectFailure(url, ...) and downloadFailure(url, ...), and in both catch blocks it copies e.getMessage() into the message text before passing the throwable. Whichever of the two merges second must do four things.

  1. Compute licenseKeys = dataFile.getConfiguration().getDataUpdateLicenseKeys() and loggedUrl = LicenseKeyMask.maskIn(url, licenseKeys) once at the top of downloadFile, and pass loggedUrl, not url, to every message and helper that names the URL. The real url is used only for new URI(url.trim()).toURL().
  2. In both catch blocks, take Throwable masked = LicenseKeyMask.maskIn(e, licenseKeys) and use masked.getMessage() for the error detail text as well as masked as the logged throwable, because an HTTP library's message names the URL.
  3. Keep DataUpdateServiceLicenseKeyTests. It captures every logger call at every level, so it fails for any message that is missed.
  4. Make the data update follow the specification #79 moves the 429, 403 and other-status lines from error to warn and drops the URL from them, so those tests' expected level changes on Make the data update follow the specification #79's side, not in the masking.

Full build

The full build and test matrix passed every job other than macOS on head cb0b80e in run 42696617119 on 9 November 2026, 10 jobs succeeded, run as a dry run so nothing merged. Every macOS job was cancelled under the 9 November decision. The rc head is built with macOS before it merges into main.

A license key in a log entry or an error message is shown only by 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 all of it. maskIn masks each key in a text, as written and as encoded in a URL, and gives a throwable to log in place of one whose message, causes or suppressed throwables hold a key, keeping the class names and stack traces.
The data update service is tested for a 403, a 429, another failing status, a 304, no response and an exception that names the URL, with two keys in the update URL. Everything it passes to the logger, at every level and with each exception's stack trace, holds each key only by its last 4 characters. The cloud request engine is tested with an error from the cloud that names the license key, and the 51Did client with a 401 body and a 400 errors list that name the licence, and with a key of 4 characters, which is replaced in full.
…errors

The data update service logged the formatted update URL, whose query carries the license keys, at debug and error level, and logged the exception an HTTP library raised, whose message often names that URL. Every message now names the URL with each configured key masked by LicenseKeyMask, and the exception is logged as a copy whose messages are masked.

The cloud answers a refused license key with an error that names it, and the cloud request engine put that text in the exception it raised, which can reach the log and the JSON builder's errors list. Its messages now mask the license key, and each key in it when it holds several separated by '|'.

The 51Did client puts the start of the body in the message of a redeem error, and the errors list of a 400 answer in the message of the IllegalArgumentException it raises. Both now mask the licence key the redeem request sent. The 51Did package depends on nothing in the Pipeline, so it masks with a private method that gives the same result as LicenseKeyMask. The exception's body is kept as the service sent it.
…e URL

maskIn_EncodedKeyShowsThePlainLast4 expects a key whose last characters
are percent-encoded in a URL to show the encoded form of its plain last
4 characters. maskIn_MasksAKeyEncodedInLowerCase expects a key encoded
with lower case hexadecimal digits to be masked too.
KeyTypedIntoTheUrl_LogsMaskedKey gives the data update service a URL
whose LicenseKeys parameter holds a key that is not among the data update
license keys, and expects the key masked in every log entry.

All three are written to fail on the previous commit.
… URL

LicenseKeyMask.maskIn now replaces a URL-encoded key with the encoded form
of its masked plain form, so it shows the same last 4 characters as the
plain key, and also masks the key encoded with lower case hexadecimal
digits. Its Javadoc now says that every occurrence of a key's text is
masked, so a key of only a few characters masks those characters
elsewhere too, as the specification asks for keys of 4 characters or
fewer. The specification link now points at rc, where the rule is.

The data update service now also masks any key in the LicenseKeys
parameter of the data update URL, such as one typed straight into the
DataUpdateUrl setting rather than given as a data update license key.

DidHttpException.getBody now says the body is not masked and can hold
the license key sent, so a caller that logs it must mask it first.
licenseKeysToMask was added between downloadFile and its Javadoc, so the
Javadoc described the wrong method. It is now above downloadFile again.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant