Skip to content
Open
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
5 changes: 5 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ jobs:
# Build from the explicit commit when chained; else the launch commit.
ref: ${{ inputs.ref || github.sha }}

# Verify the wrapper jar before the signing key / Central credentials are
# in scope, so a tampered wrapper can't execute during the publish build.
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@v4

- name: Set up JDK 17
uses: actions/setup-java@v5
with:
Expand Down
12 changes: 10 additions & 2 deletions .github/workflows/pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ jobs:
- name: Checkout
uses: actions/checkout@v7

# Verify gradle-wrapper.jar matches an official Gradle release checksum
# before it is ever executed, so a tampered wrapper committed to a branch
# can't run arbitrary code in CI. (The distribution zip itself is pinned
# via distributionSha256Sum in gradle-wrapper.properties.)
- name: Validate Gradle wrapper
uses: gradle/actions/wrapper-validation@v4

# PRs only run on JDK 17 (the minimum target). Forward-compat
# regressions on JDK 21/25 are caught post-merge by main.yml.
- name: Set up JDK 17
Expand All @@ -36,8 +43,9 @@ jobs:
distribution: temurin
java-version: '17'

# Validates the wrapper jar hash and caches Gradle home + wrapper
# dists between runs.
# Caches Gradle home + wrapper dists between runs. (Wrapper-jar integrity
# is checked by the Validate Gradle wrapper step above; the distribution
# zip is verified against distributionSha256Sum.)
- name: Set up Gradle
uses: gradle/actions/setup-gradle@v4

Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Security

- Hardened Gradle build integrity: pinned the Gradle distribution checksum
(`distributionSha256Sum`) and added a `gradle/actions/wrapper-validation` step
to the PR and publish workflows so a tampered wrapper cannot execute in CI.
- `Configuration.toString()` now redacts the API key via `Tokens.redact` instead
of printing it verbatim (a record's generated `toString` would have leaked the
raw token if the object were ever logged).
- Untrusted strings from API responses (`errmsg`, malformed date/timestamp
cells) are now sanitized before being embedded in exception/log messages,
preventing CR/LF log-forging and ANSI-escape terminal spoofing.

## [1.0.0] - 2026-06-29

First stable release of the Market Data Java & Kotlin SDK — a single JVM
Expand Down
1 change: 1 addition & 0 deletions gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
Expand Down
21 changes: 21 additions & 0 deletions src/main/java/com/marketdata/sdk/Configuration.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,27 @@ record Configuration(
private static final Set<String> ALLOWED_SCHEMES = Set.of("http", "https");
private static final Pattern API_VERSION_PATTERN = Pattern.compile("[A-Za-z0-9._-]+");

/**
* Redact the API key. A record's compiler-generated {@code toString()} prints every component
* verbatim, which would leak the raw token the moment this object is interpolated into a log line
* or debugger output; override it so {@code apiKey} is always redacted via {@link Tokens#redact}.
* The remaining fields are not secret and stay visible for diagnostics.
*/
@Override
public String toString() {
return "Configuration[apiKey="
+ Tokens.redact(apiKey)
+ ", baseUrl="
+ baseUrl
+ ", apiVersion="
+ apiVersion
+ ", loggingLevel="
+ loggingLevel
+ ", dateFormat="
+ dateFormat
+ "]";
}

/**
* Convenience overload that discards any {@link DotEnvLoader.Warning}s. Used by tests and any
* call site that does not need to replay them through a freshly-configured logger.
Expand Down
38 changes: 38 additions & 0 deletions src/main/java/com/marketdata/sdk/LogSafe.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.marketdata.sdk;

import org.jspecify.annotations.Nullable;

/**
* Neutralize untrusted text (API response strings such as {@code errmsg}, or malformed
* date/timestamp cells) before it is embedded in an exception message or log line.
*
* <p>A hostile or buggy server controls these strings. Copied verbatim into a {@code ParseError}
* message that a consumer then logs, embedded carriage-returns/newlines let an attacker forge or
* split log entries, and ANSI escape sequences can spoof a terminal. This collapses every ISO
* control character (C0/C1 range, including {@code CR}, {@code LF}, {@code TAB}, and the {@code
* ESC} that introduces ANSI sequences) to a single space, and caps the length so a 20&nbsp;MB
* response string (Jackson's default ceiling) can't blow up a log line.
*/
final class LogSafe {

/** Longest untrusted fragment we echo into a message; the rest is elided. */
static final int MAX_LEN = 200;

private LogSafe() {}

/**
* Sanitize {@code raw} for safe inclusion in a log/exception message. Never returns {@code null}.
*/
static String sanitize(@Nullable String raw) {
if (raw == null) {
return "";
}
String truncated = raw.length() > MAX_LEN ? raw.substring(0, MAX_LEN) + "…" : raw;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: Truncation can split a surrogate pair — substring(0, MAX_LEN) may leave a lone surrogate when cutting; cosmetic (replacement char in the log), no security impact.

StringBuilder out = new StringBuilder(truncated.length());
for (int i = 0; i < truncated.length(); i++) {
char c = truncated.charAt(i);
out.append(Character.isISOControl(c) ? ' ' : c);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: Unfiltered non-ISO-control Unicode — LogSafe.sanitize doesn't neutralize U+2028/U+2029 (line/paragraph separators) or U+202E (RTL override); they survive and can break a line in some log viewers or enable bidi visual spoofing.

}
return out.toString();
}
}
15 changes: 12 additions & 3 deletions src/main/java/com/marketdata/sdk/MarketDataDates.java
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ static LocalDate parseDateField(
return LocalDate.parse(node.asText());
} catch (DateTimeParseException e) {
throw new JsonMappingException(
p, "non-ISO date string for field " + fieldName + ": " + node.asText());
p,
"non-ISO date string for field " + fieldName + ": " + LogSafe.sanitize(node.asText()));
}
}
if (!node.isNumber()) {
Expand Down Expand Up @@ -115,7 +116,11 @@ static ZonedDateTime parseDateOrTimestampField(
return LocalDate.parse(text).atStartOfDay(MARKET_ZONE);
} catch (DateTimeParseException dateOnly) {
throw new JsonMappingException(
p, "non-conforming date/timestamp string for field " + fieldName + ": " + text);
p,
"non-conforming date/timestamp string for field "
+ fieldName
+ ": "
+ LogSafe.sanitize(text));
}
}
}
Expand All @@ -142,7 +147,11 @@ static ZonedDateTime parseTimestampField(
.withZoneSameInstant(MARKET_ZONE);
} catch (DateTimeParseException e) {
throw new JsonMappingException(
p, "non-conforming timestamp string for field " + fieldName + ": " + node.asText());
p,
"non-conforming timestamp string for field "
+ fieldName
+ ": "
+ LogSafe.sanitize(node.asText()));
}
}
if (!node.isNumber()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ public OptionsExpirations deserialize(JsonParser p, DeserializationContext ctxt)
String envelopeStatus = root.path(ENVELOPE_STATUS).asText("");
if (ENVELOPE_ERROR.equals(envelopeStatus)) {
throw new JsonMappingException(
p, "API responded with error: " + root.path(ENVELOPE_ERRMSG).asText("(no errmsg field)"));
p,
"API responded with error: "
+ LogSafe.sanitize(root.path(ENVELOPE_ERRMSG).asText("(no errmsg field)")));
}
if (ENVELOPE_NO_DATA.equals(envelopeStatus)) {
return new OptionsExpirations(List.of(), null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ public OptionsLookup deserialize(JsonParser p, DeserializationContext ctxt) thro
JsonNode root = p.readValueAsTree();
if (ENVELOPE_ERROR.equals(root.path(ENVELOPE_STATUS).asText(""))) {
throw new JsonMappingException(
p, "API responded with error: " + root.path(ENVELOPE_ERRMSG).asText("(no errmsg field)"));
p,
"API responded with error: "
+ LogSafe.sanitize(root.path(ENVELOPE_ERRMSG).asText("(no errmsg field)")));
}
JsonNode node = root.get(OPTION_SYMBOL_KEY);
if (node == null || !node.isTextual()) {
Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/marketdata/sdk/ParallelArrays.java
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ static <T> List<T> zip(
String envelopeStatus = root.path(ENVELOPE_STATUS).asText("");
if (ENVELOPE_ERROR.equals(envelopeStatus)) {
String errmsg = root.path(ENVELOPE_ERRMSG).asText("(no errmsg field)");
throw new JsonMappingException(p, "API responded with error: " + errmsg);
throw new JsonMappingException(p, "API responded with error: " + LogSafe.sanitize(errmsg));
}
if (ENVELOPE_NO_DATA.equals(envelopeStatus)) {
return List.of();
Expand Down
4 changes: 3 additions & 1 deletion src/main/java/com/marketdata/sdk/StockNewsDeserializer.java
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ public StockNews deserialize(JsonParser p, DeserializationContext ctxt) throws I
String envelopeStatus = root.path(ENVELOPE_STATUS).asText("");
if (ENVELOPE_ERROR.equals(envelopeStatus)) {
throw new JsonMappingException(
p, "API responded with error: " + root.path(ENVELOPE_ERRMSG).asText("(no errmsg field)"));
p,
"API responded with error: "
+ LogSafe.sanitize(root.path(ENVELOPE_ERRMSG).asText("(no errmsg field)")));
}
if (ENVELOPE_NO_DATA.equals(envelopeStatus)) {
return new StockNews(List.of(), null);
Expand Down
16 changes: 16 additions & 0 deletions src/test/java/com/marketdata/sdk/ConfigurationTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,22 @@ void resolve_uses_explicit_values_when_provided(@TempDir Path tmp) {
assertThat(config.apiVersion()).isEqualTo("v9");
}

@Test
void toString_redacts_the_api_key_but_keeps_other_fields() {
Configuration config =
new Configuration(
"supersecrettoken1234567890", "https://api.marketdata.app", "v1", "INFO", "timestamp");

String rendered = config.toString();

// The token must never appear verbatim; only the redacted marker + last 4 chars.
assertThat(rendered).doesNotContain("supersecrettoken");
assertThat(rendered).contains(Tokens.redact("supersecrettoken1234567890"));
// Non-secret fields stay visible for diagnostics.
assertThat(rendered).contains("https://api.marketdata.app");
assertThat(rendered).contains("v1");
}

@Test
void resolve_falls_back_to_env_when_explicit_missing(@TempDir Path tmp) {
Configuration config =
Expand Down
Binary file added src/test/java/com/marketdata/sdk/LogSafeTest.java
Binary file not shown.
Loading