diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3a8c65d..2f85213 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -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: diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index d84a13d..40345d4 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -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 @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 53c39be..01fd791 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a351597..e3ec95f 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/src/main/java/com/marketdata/sdk/Configuration.java b/src/main/java/com/marketdata/sdk/Configuration.java index aaa2bf3..ae5b045 100644 --- a/src/main/java/com/marketdata/sdk/Configuration.java +++ b/src/main/java/com/marketdata/sdk/Configuration.java @@ -24,6 +24,27 @@ record Configuration( private static final Set 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. diff --git a/src/main/java/com/marketdata/sdk/LogSafe.java b/src/main/java/com/marketdata/sdk/LogSafe.java new file mode 100644 index 0000000..b31e189 --- /dev/null +++ b/src/main/java/com/marketdata/sdk/LogSafe.java @@ -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. + * + *

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 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; + 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); + } + return out.toString(); + } +} diff --git a/src/main/java/com/marketdata/sdk/MarketDataDates.java b/src/main/java/com/marketdata/sdk/MarketDataDates.java index f9a3148..2ee092f 100644 --- a/src/main/java/com/marketdata/sdk/MarketDataDates.java +++ b/src/main/java/com/marketdata/sdk/MarketDataDates.java @@ -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()) { @@ -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)); } } } @@ -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()) { diff --git a/src/main/java/com/marketdata/sdk/OptionsExpirationsDeserializer.java b/src/main/java/com/marketdata/sdk/OptionsExpirationsDeserializer.java index 4ea1b0c..b6da349 100644 --- a/src/main/java/com/marketdata/sdk/OptionsExpirationsDeserializer.java +++ b/src/main/java/com/marketdata/sdk/OptionsExpirationsDeserializer.java @@ -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); diff --git a/src/main/java/com/marketdata/sdk/OptionsLookupDeserializer.java b/src/main/java/com/marketdata/sdk/OptionsLookupDeserializer.java index 9b909cb..bbb81b7 100644 --- a/src/main/java/com/marketdata/sdk/OptionsLookupDeserializer.java +++ b/src/main/java/com/marketdata/sdk/OptionsLookupDeserializer.java @@ -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()) { diff --git a/src/main/java/com/marketdata/sdk/ParallelArrays.java b/src/main/java/com/marketdata/sdk/ParallelArrays.java index 5159908..04be90c 100644 --- a/src/main/java/com/marketdata/sdk/ParallelArrays.java +++ b/src/main/java/com/marketdata/sdk/ParallelArrays.java @@ -96,7 +96,7 @@ static List 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(); diff --git a/src/main/java/com/marketdata/sdk/StockNewsDeserializer.java b/src/main/java/com/marketdata/sdk/StockNewsDeserializer.java index fd53c58..375ea81 100644 --- a/src/main/java/com/marketdata/sdk/StockNewsDeserializer.java +++ b/src/main/java/com/marketdata/sdk/StockNewsDeserializer.java @@ -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); diff --git a/src/test/java/com/marketdata/sdk/ConfigurationTest.java b/src/test/java/com/marketdata/sdk/ConfigurationTest.java index 0ae3160..ad93b50 100644 --- a/src/test/java/com/marketdata/sdk/ConfigurationTest.java +++ b/src/test/java/com/marketdata/sdk/ConfigurationTest.java @@ -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 = diff --git a/src/test/java/com/marketdata/sdk/LogSafeTest.java b/src/test/java/com/marketdata/sdk/LogSafeTest.java new file mode 100644 index 0000000..877dc27 Binary files /dev/null and b/src/test/java/com/marketdata/sdk/LogSafeTest.java differ