From 8d7d8aa3d74d1f03dab43b4d669315171b8e4366 Mon Sep 17 00:00:00 2001 From: Peter Paul Bakker Date: Thu, 5 Nov 2026 13:07:56 +0000 Subject: [PATCH 1/2] feat: log trust store certificate counts and blocklist summary Log the number of trusted certificates for the system and container trust managers, plus the system trust store source and the JDK blocklist summary. The system summary is logged at INFO once per JVM and at FINE afterwards, so it does not bring back the per-connection noise fixed in #17. --- ...udFoundryContainerTrustManagerFactory.java | 28 +++- .../FileWatchingX509ExtendedTrustManager.java | 6 +- .../security/TrustStoreDiagnostics.java | 100 ++++++++++++ ...undryContainerTrustManagerFactoryTest.java | 92 ++++++++++- ...eWatchingX509ExtendedTrustManagerTest.java | 40 +++-- .../cloudfoundry/security/LogRecorder.java | 17 +- .../security/TrustStoreDiagnosticsTest.java | 145 ++++++++++++++++++ 7 files changed, 399 insertions(+), 29 deletions(-) create mode 100644 src/main/java/org/cloudfoundry/security/TrustStoreDiagnostics.java create mode 100644 src/test/java/org/cloudfoundry/security/TrustStoreDiagnosticsTest.java diff --git a/src/main/java/org/cloudfoundry/security/CloudFoundryContainerTrustManagerFactory.java b/src/main/java/org/cloudfoundry/security/CloudFoundryContainerTrustManagerFactory.java index ad1025d..5be9095 100644 --- a/src/main/java/org/cloudfoundry/security/CloudFoundryContainerTrustManagerFactory.java +++ b/src/main/java/org/cloudfoundry/security/CloudFoundryContainerTrustManagerFactory.java @@ -32,6 +32,7 @@ import java.security.NoSuchProviderException; import java.util.ArrayList; import java.util.List; +import java.util.logging.Level; import java.util.logging.Logger; abstract class CloudFoundryContainerTrustManagerFactory extends TrustManagerFactorySpi { @@ -44,6 +45,8 @@ abstract class CloudFoundryContainerTrustManagerFactory extends TrustManagerFact private static FileWatchingX509ExtendedTrustManager CACHED_CONTAINER_TRUST_MANAGER; + private static boolean SYSTEM_SUMMARY_LOGGED; + private final Logger logger = Logger.getLogger(this.getClass().getName()); private final String algorithm; @@ -54,6 +57,8 @@ abstract class CloudFoundryContainerTrustManagerFactory extends TrustManagerFact private X509ExtendedTrustManager cachedSystemTrustManager; + private String systemTrustSource; + private CloudFoundryContainerTrustManagerFactory(String algorithm, Path certificates) { this.algorithm = algorithm; this.certificates = certificates; @@ -83,13 +88,15 @@ protected final TrustManager[] engineGetTrustManagers() { @Override protected final void engineInit(ManagerFactoryParameters managerFactoryParameters) throws InvalidAlgorithmParameterException { this.systemTrustManagerFactory.init(managerFactoryParameters); - invalidateSystemTrustManager(); + invalidateSystemTrustManager("ManagerFactoryParameters passed to init"); } @Override protected final void engineInit(KeyStore keyStore) throws KeyStoreException { this.systemTrustManagerFactory.init(keyStore); - invalidateSystemTrustManager(); + // init(null) reads the JVM trust store configuration now, so describe it now rather than when the managers are retrieved. + invalidateSystemTrustManager(keyStore != null ? "KeyStore passed to init" + : TrustStoreDiagnostics.systemTrustStore()); } private static Path getCertificatesLocation() { @@ -113,8 +120,8 @@ private X509ExtendedTrustManager getSystemTrustManager() { if (this.cachedSystemTrustManager == null) { for (TrustManager candidate : this.systemTrustManagerFactory.getTrustManagers()) { if (candidate instanceof X509ExtendedTrustManager) { - this.logger.fine("Adding System Trust Manager"); this.cachedSystemTrustManager = (X509ExtendedTrustManager) candidate; + logSystemSummary(); break; } } @@ -124,6 +131,18 @@ private X509ExtendedTrustManager getSystemTrustManager() { } } + // Logged at INFO once per JVM; factories are created often (e.g. per connection), so later ones log at FINE (see #17). + private void logSystemSummary() { + Level level = SYSTEM_SUMMARY_LOGGED ? Level.FINE : Level.INFO; + SYSTEM_SUMMARY_LOGGED = true; + + this.logger.log(level, TrustStoreDiagnostics.safe(() -> String.format("Adding System TrustManager (%s) with %d trusted certificates", + this.algorithm, this.cachedSystemTrustManager.getAcceptedIssuers().length))); + this.logger.log(level, TrustStoreDiagnostics.safe(() -> String.format("System trust store: %s", this.systemTrustSource != null + ? this.systemTrustSource : TrustStoreDiagnostics.systemTrustStore()))); + this.logger.log(level, TrustStoreDiagnostics.safe(() -> String.format("Blocked: %s", TrustStoreDiagnostics.blocked()))); + } + private TrustManagerFactory getTrustManagerFactory() { try { return TrustManagerFactory.getInstance(this.algorithm, "SunJSSE"); @@ -132,9 +151,10 @@ private TrustManagerFactory getTrustManagerFactory() { } } - private void invalidateSystemTrustManager() { + private void invalidateSystemTrustManager(String source) { synchronized (MONITOR) { this.cachedSystemTrustManager = null; + this.systemTrustSource = source; } } diff --git a/src/main/java/org/cloudfoundry/security/FileWatchingX509ExtendedTrustManager.java b/src/main/java/org/cloudfoundry/security/FileWatchingX509ExtendedTrustManager.java index a3e16ef..d4fb54c 100644 --- a/src/main/java/org/cloudfoundry/security/FileWatchingX509ExtendedTrustManager.java +++ b/src/main/java/org/cloudfoundry/security/FileWatchingX509ExtendedTrustManager.java @@ -49,7 +49,7 @@ final class FileWatchingX509ExtendedTrustManager extends X509ExtendedTrustManage new FileWatcher(this.certificates, new FileWatcherCallback()).watch(); if (this.trustManager.compareAndSet(null, getTrustManager(getKeyStore()))) { - this.logger.info(() -> String.format("Initialized TrustManager for %s", this.certificates)); + this.logger.info(TrustStoreDiagnostics.safe(() -> String.format("Initialized TrustManager for %s with %d trusted certificates", this.certificates, this.trustManager.get().getAcceptedIssuers().length))); } } @@ -123,9 +123,9 @@ private class FileWatcherCallback implements Runnable { @Override public void run() { if (FileWatchingX509ExtendedTrustManager.this.trustManager.getAndSet(getTrustManager(getKeyStore())) == null) { - FileWatchingX509ExtendedTrustManager.this.logger.info(() -> String.format("Initialized TrustManager for %s", FileWatchingX509ExtendedTrustManager.this.certificates)); + FileWatchingX509ExtendedTrustManager.this.logger.info(TrustStoreDiagnostics.safe(() -> String.format("Initialized TrustManager for %s with %d trusted certificates", FileWatchingX509ExtendedTrustManager.this.certificates, FileWatchingX509ExtendedTrustManager.this.getAcceptedIssuers().length))); } else { - FileWatchingX509ExtendedTrustManager.this.logger.info(() -> String.format("Updated TrustManager for %s", FileWatchingX509ExtendedTrustManager.this.certificates)); + FileWatchingX509ExtendedTrustManager.this.logger.info(TrustStoreDiagnostics.safe(() -> String.format("Updated TrustManager for %s with %d trusted certificates", FileWatchingX509ExtendedTrustManager.this.certificates, FileWatchingX509ExtendedTrustManager.this.getAcceptedIssuers().length))); } } diff --git a/src/main/java/org/cloudfoundry/security/TrustStoreDiagnostics.java b/src/main/java/org/cloudfoundry/security/TrustStoreDiagnostics.java new file mode 100644 index 0000000..e02fcf7 --- /dev/null +++ b/src/main/java/org/cloudfoundry/security/TrustStoreDiagnostics.java @@ -0,0 +1,100 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.cloudfoundry.security; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.Security; +import java.util.List; +import java.util.function.Supplier; +import java.util.regex.Pattern; + +/** + * Describes, for logging, which system trust store the JVM uses and which certificates the JDK blocks or distrusts. + */ +final class TrustStoreDiagnostics { + + private static final Pattern FINGERPRINT = Pattern.compile("[0-9A-Fa-f]{40,}=?"); + + private TrustStoreDiagnostics() { + } + + // Reports the configuration as set, without interpreting it: the JDK does not expose which store it loaded, and its rules + // (defaults, empty or special values, store types) are internal. The certificate count shows what was actually loaded. + static String systemTrustStore() { + return bestEffort(() -> { + String store = System.getProperty("javax.net.ssl.trustStore"); + if (store == null) { + return "default JVM configuration (javax.net.ssl.trustStore not set)"; + } + + String type = System.getProperty("javax.net.ssl.trustStoreType"); + return String.format("default JVM configuration (javax.net.ssl.trustStore=%s, javax.net.ssl.trustStoreType=%s)", + store, type != null ? type : "not set"); + }); + } + + static String blocked() { + return bestEffort(() -> blocked(Paths.get(System.getProperty("java.home", ""), "lib", "security"))); + } + + // Diagnostics must never prevent TLS setup, for example when a SecurityManager denies a property or file read or a path is malformed. + static String bestEffort(Supplier diagnostic) { + try { + return diagnostic.get(); + } catch (SecurityException e) { + return "unavailable (access denied)"; + } catch (RuntimeException e) { + return "unavailable (" + e.getClass().getSimpleName() + ")"; + } + } + + static Supplier safe(Supplier message) { + return () -> bestEffort(message); + } + + static String blocked(Path security) { + String distrust = Security.getProperty("jdk.security.caDistrustPolicies"); + String policies = distrust == null || distrust.isEmpty() ? "none" : distrust; + Integer count = countBlockedCertificates(security); + return count == null + ? String.format("unknown (no readable blocked.certs or blacklisted.certs), caDistrustPolicies=%s", policies) + : String.format("%d blocked certificates, caDistrustPolicies=%s", count, policies); + } + + // Java 8 names the file blacklisted.certs; newer JDKs use blocked.certs. Returns null when neither is readable. + private static Integer countBlockedCertificates(Path security) { + for (String name : new String[]{"blocked.certs", "blacklisted.certs"}) { + try { + List lines = Files.readAllLines(security.resolve(name)); + return (int) lines.stream().filter(TrustStoreDiagnostics::isFingerprintLine).count(); + } catch (IOException e) { + // try the next name + } + } + + return null; + } + + // The blocklist holds one hex fingerprint per line (optionally with an empty "=" value), mixed with comments and blank lines. + private static boolean isFingerprintLine(String line) { + return FINGERPRINT.matcher(line.trim()).matches(); + } + +} diff --git a/src/test/java/org/cloudfoundry/security/CloudFoundryContainerTrustManagerFactoryTest.java b/src/test/java/org/cloudfoundry/security/CloudFoundryContainerTrustManagerFactoryTest.java index 71566c0..cf137e6 100644 --- a/src/test/java/org/cloudfoundry/security/CloudFoundryContainerTrustManagerFactoryTest.java +++ b/src/test/java/org/cloudfoundry/security/CloudFoundryContainerTrustManagerFactoryTest.java @@ -26,6 +26,7 @@ import java.security.NoSuchAlgorithmException; import java.security.NoSuchProviderException; import java.security.cert.CertificateException; +import java.util.ArrayList; import java.util.List; import java.util.logging.Level; import java.util.logging.LogRecord; @@ -54,15 +55,102 @@ public void defaultTrustManagerForNullCertificatesLocation() throws CertificateE assertThat(((DelegatingX509ExtendedTrustManager) trustManager).size()).isEqualTo(1); } + // Logging the summary at INFO for every factory init (e.g. per new connection) was the problem fixed in #17. @Test - public void logsSystemTrustManagerAtFine() throws Exception { + public void laterSystemTrustManagersLogSummaryAtFine() throws Exception { + recordSystemSummaryLevels(); + + assertThat(recordSystemSummaryLevels()).containsExactly(Level.FINE, Level.FINE, Level.FINE); + } + + @Test + public void systemSummaryContainsCertificateCount() throws Exception { + + assertThat(recordSystemSummary()).extracting(LogRecord::getMessage).anyMatch(m -> m.matches("Adding System TrustManager \\(PKIX\\) with \\d+ trusted certificates")); + } + + @Test + public void logsSuppliedKeyStoreAsSystemTrustStoreSource() throws Exception { List records = LogRecorder.record(CloudFoundryContainerTrustManagerFactory.PKIXFactory.class, () -> { CloudFoundryContainerTrustManagerFactory.PKIXFactory factory = new CloudFoundryContainerTrustManagerFactory.PKIXFactory(null); factory.engineInit(getKeyStore()); factory.engineGetTrustManagers(); }); - assertThat(records).filteredOn(r -> "Adding System Trust Manager".equals(r.getMessage())).extracting(LogRecord::getLevel).containsExactly(Level.FINE); + assertThat(records).extracting(LogRecord::getMessage).contains("System trust store: KeyStore passed to init"); + } + + @Test + public void systemTrustStoreSourceIsCapturedAtInit() throws Exception { + String original = System.getProperty("javax.net.ssl.trustStore"); + String cacerts = Paths.get(System.getProperty("java.home"), "lib", "security", "cacerts").toString(); + + List records = LogRecorder.record(CloudFoundryContainerTrustManagerFactory.PKIXFactory.class, () -> { + CloudFoundryContainerTrustManagerFactory.PKIXFactory factory = new CloudFoundryContainerTrustManagerFactory.PKIXFactory(null); + System.setProperty("javax.net.ssl.trustStore", cacerts); + try { + factory.engineInit((KeyStore) null); + } finally { + restoreProperty("javax.net.ssl.trustStore", original); + } + factory.engineGetTrustManagers(); + }); + + assertThat(records).extracting(LogRecord::getMessage) + .contains("System trust store: default JVM configuration (javax.net.ssl.trustStore=" + cacerts + ", javax.net.ssl.trustStoreType=not set)"); + } + + @Test + public void summaryFailureDoesNotBreakInit() throws Exception { + String javaHome = System.getProperty("java.home"); + System.setProperty("java.home", "bad\0path"); + try { + List records = LogRecorder.record(CloudFoundryContainerTrustManagerFactory.PKIXFactory.class, () -> { + CloudFoundryContainerTrustManagerFactory.PKIXFactory factory = new CloudFoundryContainerTrustManagerFactory.PKIXFactory(null); + factory.engineInit((KeyStore) null); + assertThat(factory.engineGetTrustManagers()).isNotEmpty(); + }); + + assertThat(records).extracting(LogRecord::getMessage).contains("Blocked: unavailable (InvalidPathException)"); + } finally { + System.setProperty("java.home", javaHome); + } + } + + private List recordSystemSummaryLevels() throws Exception { + List levels = new ArrayList<>(); + for (LogRecord record : recordSystemSummary()) { + levels.add(record.getLevel()); + } + + return levels; + } + + private static void restoreProperty(String key, String value) { + if (value == null) { + System.clearProperty(key); + } else { + System.setProperty(key, value); + } + } + + private List recordSystemSummary() throws Exception { + List records = LogRecorder.record(CloudFoundryContainerTrustManagerFactory.PKIXFactory.class, () -> { + CloudFoundryContainerTrustManagerFactory.PKIXFactory factory = new CloudFoundryContainerTrustManagerFactory.PKIXFactory(null); + factory.engineInit(getKeyStore()); + factory.engineGetTrustManagers(); + }); + + List summary = new ArrayList<>(); + for (LogRecord record : records) { + String message = record.getMessage(); + if (message.startsWith("Adding System TrustManager") || message.startsWith("System trust store:") + || message.startsWith("Blocked:")) { + summary.add(record); + } + } + + return summary; } private KeyStore getKeyStore() throws KeyStoreException, CertificateException, NoSuchAlgorithmException, IOException { diff --git a/src/test/java/org/cloudfoundry/security/FileWatchingX509ExtendedTrustManagerTest.java b/src/test/java/org/cloudfoundry/security/FileWatchingX509ExtendedTrustManagerTest.java index 0147a9a..541f2fc 100644 --- a/src/test/java/org/cloudfoundry/security/FileWatchingX509ExtendedTrustManagerTest.java +++ b/src/test/java/org/cloudfoundry/security/FileWatchingX509ExtendedTrustManagerTest.java @@ -25,6 +25,8 @@ import java.nio.file.Paths; import java.nio.file.StandardCopyOption; import java.security.NoSuchAlgorithmException; +import java.util.List; +import java.util.logging.LogRecord; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.fail; @@ -42,32 +44,36 @@ public void initializedWithWatchedFile() throws IOException, NoSuchAlgorithmExce } @Test - public void watchesWatchedFile() throws IOException, InterruptedException, NoSuchAlgorithmException { + public void watchesWatchedFile() throws Exception { Path watchedCertificates = getWatchedFile(); Files.copy(Paths.get("src/test/resources/server-certificates-48.pem"), watchedCertificates); + String expectedMessage = String.format("Updated TrustManager for %s with 173 trusted certificates", watchedCertificates); - FileWatchingX509ExtendedTrustManager trustManager = new FileWatchingX509ExtendedTrustManager(watchedCertificates, TrustManagerFactory.getInstance("PKIX")); + List records = LogRecorder.record(FileWatchingX509ExtendedTrustManager.class, (List live) -> { + FileWatchingX509ExtendedTrustManager trustManager = new FileWatchingX509ExtendedTrustManager(watchedCertificates, TrustManagerFactory.getInstance("PKIX")); - assertThat(trustManager.getAcceptedIssuers()).hasSize(48); + assertThat(trustManager.getAcceptedIssuers()).hasSize(48); - Thread.sleep(5_000); - // Replace atomically, as a rename, so no update sees a half-written file. - Path replacement = Files.createTempFile(watchedCertificates.getParent(), "certificates-", ".tmp"); - Files.copy(Paths.get("src/test/resources/server-certificates-173.pem"), replacement, StandardCopyOption.REPLACE_EXISTING); - Files.move(replacement, watchedCertificates, StandardCopyOption.ATOMIC_MOVE); + Thread.sleep(5_000); + // Replace atomically, as a rename, so no update sees a half-written file. + Path replacement = Files.createTempFile(watchedCertificates.getParent(), "certificates-", ".tmp"); + Files.copy(Paths.get("src/test/resources/server-certificates-173.pem"), replacement, StandardCopyOption.REPLACE_EXISTING); + Files.move(replacement, watchedCertificates, StandardCopyOption.ATOMIC_MOVE); - long timeout = System.currentTimeMillis() + 30_000; - for (; ; ) { - if (System.currentTimeMillis() > timeout) { - fail("Failed to update within timeout"); - } + // The summary is logged asynchronously, after the manager has been updated. + long timeout = System.currentTimeMillis() + 30_000; + while (live.stream().noneMatch(record -> expectedMessage.equals(record.getMessage()))) { + if (System.currentTimeMillis() > timeout) { + fail("Failed to update within timeout"); + } - if (trustManager.getAcceptedIssuers().length == 173) { - return; + Thread.sleep(1_000); } - Thread.sleep(1_000); - } + assertThat(trustManager.getAcceptedIssuers()).hasSize(173); + }); + + assertThat(records).extracting(LogRecord::getMessage).contains(expectedMessage); } private Path getWatchedFile() throws IOException { diff --git a/src/test/java/org/cloudfoundry/security/LogRecorder.java b/src/test/java/org/cloudfoundry/security/LogRecorder.java index af13fae..032a9d8 100644 --- a/src/test/java/org/cloudfoundry/security/LogRecorder.java +++ b/src/test/java/org/cloudfoundry/security/LogRecorder.java @@ -16,8 +16,8 @@ package org.cloudfoundry.security; -import java.util.ArrayList; import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.logging.Handler; import java.util.logging.Level; import java.util.logging.LogRecord; @@ -30,8 +30,13 @@ private LogRecorder() { // Records everything logged to the logger named after the given class while the action runs, at all levels. static List record(Class loggerClass, Action action) throws Exception { + return record(loggerClass, records -> action.run()); + } + + // Like record(Class, Action), but hands the live record list to the action so it can wait for asynchronous log output. + static List record(Class loggerClass, RecordingAction action) throws Exception { Logger logger = Logger.getLogger(loggerClass.getName()); - List records = new ArrayList<>(); + List records = new CopyOnWriteArrayList<>(); Handler handler = new Handler() { @Override @@ -53,7 +58,7 @@ public void close() { logger.addHandler(handler); logger.setLevel(Level.ALL); try { - action.run(); + action.run(records); } finally { logger.removeHandler(handler); logger.setLevel(level); @@ -62,6 +67,12 @@ public void close() { return records; } + interface RecordingAction { + + void run(List records) throws Exception; + + } + interface Action { void run() throws Exception; diff --git a/src/test/java/org/cloudfoundry/security/TrustStoreDiagnosticsTest.java b/src/test/java/org/cloudfoundry/security/TrustStoreDiagnosticsTest.java new file mode 100644 index 0000000..18a9a43 --- /dev/null +++ b/src/test/java/org/cloudfoundry/security/TrustStoreDiagnosticsTest.java @@ -0,0 +1,145 @@ +/* + * Copyright 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.cloudfoundry.security; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +public class TrustStoreDiagnosticsTest { + + private static final String FINGERPRINT_1 = "03DB9E5E79FE6117177F81C11595AF598CB176AF766290DBCEB2C318B32E39A2"; + + private static final String FINGERPRINT_2 = "08C396C006A21055D00826A5781A5CCFCE2C8D053AB3C197637A4A7A5BB9A650"; + + private static final String FINGERPRINT_3 = "14E6D2764A4B06701C6CBC376A253775F79C782FBCB6C0EE6F99DE4BA102B9C0"; + + @Rule + public final TemporaryFolder securityDirectory = new TemporaryFolder(); + + private String originalTrustStore; + + private String originalTrustStoreType; + + @Before + public void saveTrustStoreProperties() { + this.originalTrustStore = System.getProperty("javax.net.ssl.trustStore"); + this.originalTrustStoreType = System.getProperty("javax.net.ssl.trustStoreType"); + System.clearProperty("javax.net.ssl.trustStore"); + System.clearProperty("javax.net.ssl.trustStoreType"); + } + + @After + public void restoreTrustStoreProperties() { + restore("javax.net.ssl.trustStore", this.originalTrustStore); + restore("javax.net.ssl.trustStoreType", this.originalTrustStoreType); + } + + @Test + public void systemTrustStoreReportsConfiguredPropertyAndType() { + System.setProperty("javax.net.ssl.trustStore", "/custom/store.p12"); + System.setProperty("javax.net.ssl.trustStoreType", "PKCS12"); + + assertThat(TrustStoreDiagnostics.systemTrustStore()) + .isEqualTo("default JVM configuration (javax.net.ssl.trustStore=/custom/store.p12, javax.net.ssl.trustStoreType=PKCS12)"); + } + + @Test + public void systemTrustStoreReportsValuesAsSetWithoutInterpreting() { + System.setProperty("javax.net.ssl.trustStore", "NONE"); + + assertThat(TrustStoreDiagnostics.systemTrustStore()) + .isEqualTo("default JVM configuration (javax.net.ssl.trustStore=NONE, javax.net.ssl.trustStoreType=not set)"); + } + + @Test + public void systemTrustStoreReportsUnsetProperty() { + assertThat(TrustStoreDiagnostics.systemTrustStore()) + .isEqualTo("default JVM configuration (javax.net.ssl.trustStore not set)"); + } + + @Test + public void blockedCountsFingerprintsAndIgnoresCommentsAndBlankLines() throws IOException { + writeBlocklist("blocked.certs", "# comment", "", FINGERPRINT_1, " " + FINGERPRINT_2 + " ", FINGERPRINT_3 + "="); + + assertThat(TrustStoreDiagnostics.blocked(this.securityDirectory.getRoot().toPath())) + .startsWith("3 blocked certificates, caDistrustPolicies="); + } + + @Test + public void blockedFallsBackToLegacyJava8FileName() throws IOException { + writeBlocklist("blacklisted.certs", "# comment", FINGERPRINT_1); + + assertThat(TrustStoreDiagnostics.blocked(this.securityDirectory.getRoot().toPath())) + .startsWith("1 blocked certificates, caDistrustPolicies="); + } + + @Test + public void blockedIsUnknownWhenNoBlocklistIsReadable() { + assertThat(TrustStoreDiagnostics.blocked(this.securityDirectory.getRoot().toPath())) + .startsWith("unknown (no readable blocked.certs or blacklisted.certs), caDistrustPolicies="); + } + + @Test + public void bestEffortReturnsDiagnostic() { + assertThat(TrustStoreDiagnostics.bestEffort(() -> "diagnostic")).isEqualTo("diagnostic"); + } + + @Test + public void bestEffortReportsUnavailableWhenAccessIsDenied() { + assertThat(TrustStoreDiagnostics.bestEffort(() -> { + throw new SecurityException("denied"); + })).isEqualTo("unavailable (access denied)"); + } + + @Test + public void bestEffortReportsUnavailableForUnexpectedFailure() { + assertThat(TrustStoreDiagnostics.bestEffort(() -> { + throw new IllegalStateException("boom"); + })).isEqualTo("unavailable (IllegalStateException)"); + } + + @Test + public void safeSupplierNeverThrows() { + assertThat(TrustStoreDiagnostics.safe(() -> { + throw new IllegalArgumentException("bad path"); + }).get()).isEqualTo("unavailable (IllegalArgumentException)"); + } + + private static void restore(String key, String value) { + if (value == null) { + System.clearProperty(key); + } else { + System.setProperty(key, value); + } + } + + private void writeBlocklist(String name, String... lines) throws IOException { + File blocklist = new File(this.securityDirectory.getRoot(), name); + Files.write(blocklist.toPath(), Arrays.asList(lines)); + } + +} From 8d964b35274ff73f66f899ad3555d61ba65752d0 Mon Sep 17 00:00:00 2001 From: Peter Paul Bakker Date: Fri, 6 Nov 2026 13:03:07 +0000 Subject: [PATCH 2/2] fix: log summary levels in isolation and report trust store type independently Report javax.net.ssl.trustStore and trustStoreType as set, and only when at least one is set, instead of guessing which store the JDK loads. Test the INFO-once-then-FINE summary in an isolated class loader. --- ...udFoundryContainerTrustManagerFactory.java | 5 +- .../security/TrustStoreDiagnostics.java | 20 ++++--- ...undryContainerTrustManagerFactoryTest.java | 54 +++++++++++++++---- .../security/TrustStoreDiagnosticsTest.java | 15 ++++-- 4 files changed, 70 insertions(+), 24 deletions(-) diff --git a/src/main/java/org/cloudfoundry/security/CloudFoundryContainerTrustManagerFactory.java b/src/main/java/org/cloudfoundry/security/CloudFoundryContainerTrustManagerFactory.java index 5be9095..7369bfd 100644 --- a/src/main/java/org/cloudfoundry/security/CloudFoundryContainerTrustManagerFactory.java +++ b/src/main/java/org/cloudfoundry/security/CloudFoundryContainerTrustManagerFactory.java @@ -138,8 +138,9 @@ private void logSystemSummary() { this.logger.log(level, TrustStoreDiagnostics.safe(() -> String.format("Adding System TrustManager (%s) with %d trusted certificates", this.algorithm, this.cachedSystemTrustManager.getAcceptedIssuers().length))); - this.logger.log(level, TrustStoreDiagnostics.safe(() -> String.format("System trust store: %s", this.systemTrustSource != null - ? this.systemTrustSource : TrustStoreDiagnostics.systemTrustStore()))); + if (this.systemTrustSource != null) { + this.logger.log(level, () -> String.format("System trust store: %s", this.systemTrustSource)); + } this.logger.log(level, TrustStoreDiagnostics.safe(() -> String.format("Blocked: %s", TrustStoreDiagnostics.blocked()))); } diff --git a/src/main/java/org/cloudfoundry/security/TrustStoreDiagnostics.java b/src/main/java/org/cloudfoundry/security/TrustStoreDiagnostics.java index e02fcf7..f39d642 100644 --- a/src/main/java/org/cloudfoundry/security/TrustStoreDiagnostics.java +++ b/src/main/java/org/cloudfoundry/security/TrustStoreDiagnostics.java @@ -35,21 +35,25 @@ final class TrustStoreDiagnostics { private TrustStoreDiagnostics() { } - // Reports the configuration as set, without interpreting it: the JDK does not expose which store it loaded, and its rules - // (defaults, empty or special values, store types) are internal. The certificate count shows what was actually loaded. + // Reports the trust store properties as set, without interpreting them: the JDK does not expose which store it loaded, and its + // rules (defaults, empty or special values, store types) are internal. The certificate count shows what was actually loaded. + // Returns null when neither property is set, which is the default and needs no logging. static String systemTrustStore() { return bestEffort(() -> { - String store = System.getProperty("javax.net.ssl.trustStore"); - if (store == null) { - return "default JVM configuration (javax.net.ssl.trustStore not set)"; + if (System.getProperty("javax.net.ssl.trustStore") == null && System.getProperty("javax.net.ssl.trustStoreType") == null) { + return null; } - String type = System.getProperty("javax.net.ssl.trustStoreType"); - return String.format("default JVM configuration (javax.net.ssl.trustStore=%s, javax.net.ssl.trustStoreType=%s)", - store, type != null ? type : "not set"); + return String.format("javax.net.ssl.trustStore=%s, javax.net.ssl.trustStoreType=%s", + valueOrNotSet("javax.net.ssl.trustStore"), valueOrNotSet("javax.net.ssl.trustStoreType")); }); } + private static String valueOrNotSet(String property) { + String value = System.getProperty(property); + return value != null ? value : "not set"; + } + static String blocked() { return bestEffort(() -> blocked(Paths.get(System.getProperty("java.home", ""), "lib", "security"))); } diff --git a/src/test/java/org/cloudfoundry/security/CloudFoundryContainerTrustManagerFactoryTest.java b/src/test/java/org/cloudfoundry/security/CloudFoundryContainerTrustManagerFactoryTest.java index cf137e6..450e660 100644 --- a/src/test/java/org/cloudfoundry/security/CloudFoundryContainerTrustManagerFactoryTest.java +++ b/src/test/java/org/cloudfoundry/security/CloudFoundryContainerTrustManagerFactoryTest.java @@ -20,6 +20,11 @@ import javax.net.ssl.TrustManager; import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Path; import java.nio.file.Paths; import java.security.KeyStore; import java.security.KeyStoreException; @@ -57,10 +62,22 @@ public void defaultTrustManagerForNullCertificatesLocation() throws CertificateE // Logging the summary at INFO for every factory init (e.g. per new connection) was the problem fixed in #17. @Test - public void laterSystemTrustManagersLogSummaryAtFine() throws Exception { - recordSystemSummaryLevels(); + public void logsSummaryAtInfoOnceThenAtFine() throws Exception { + String store = System.getProperty("javax.net.ssl.trustStore"); + String type = System.getProperty("javax.net.ssl.trustStoreType"); + System.clearProperty("javax.net.ssl.trustStore"); + System.clearProperty("javax.net.ssl.trustStoreType"); + List records; + try { + records = recordSummaryOfTwoInitsInIsolatedClassLoader(); + } finally { + restoreProperty("javax.net.ssl.trustStore", store); + restoreProperty("javax.net.ssl.trustStoreType", type); + } - assertThat(recordSystemSummaryLevels()).containsExactly(Level.FINE, Level.FINE, Level.FINE); + // With the default configuration there is no trust store line: only the count and the blocklist. + assertThat(records).extracting(LogRecord::getMessage).noneMatch(m -> m.startsWith("System trust store:")); + assertThat(records).extracting(LogRecord::getLevel).containsExactly(Level.INFO, Level.INFO, Level.FINE, Level.FINE); } @Test @@ -97,7 +114,7 @@ public void systemTrustStoreSourceIsCapturedAtInit() throws Exception { }); assertThat(records).extracting(LogRecord::getMessage) - .contains("System trust store: default JVM configuration (javax.net.ssl.trustStore=" + cacerts + ", javax.net.ssl.trustStoreType=not set)"); + .contains("System trust store: javax.net.ssl.trustStore=" + cacerts + ", javax.net.ssl.trustStoreType=not set"); } @Test @@ -117,13 +134,26 @@ public void summaryFailureDoesNotBreakInit() throws Exception { } } - private List recordSystemSummaryLevels() throws Exception { - List levels = new ArrayList<>(); - for (LogRecord record : recordSystemSummary()) { - levels.add(record.getLevel()); + // The once-per-JVM state is static, so a fresh class loader gives every run a clean state without test hooks in the main code. + private List recordSummaryOfTwoInitsInIsolatedClassLoader() throws Exception { + URL classes = Paths.get("target/classes").toUri().toURL(); + try (URLClassLoader isolated = new URLClassLoader(new URL[]{classes}, ClassLoader.getSystemClassLoader().getParent())) { + Class factoryClass = isolated.loadClass(CloudFoundryContainerTrustManagerFactory.PKIXFactory.class.getName()); + Constructor constructor = factoryClass.getDeclaredConstructor(Path.class); + constructor.setAccessible(true); + Method init = factoryClass.getSuperclass().getDeclaredMethod("engineInit", KeyStore.class); + init.setAccessible(true); + Method getTrustManagers = factoryClass.getSuperclass().getDeclaredMethod("engineGetTrustManagers"); + getTrustManagers.setAccessible(true); + + return filterSummary(LogRecorder.record(CloudFoundryContainerTrustManagerFactory.PKIXFactory.class, () -> { + for (int i = 0; i < 2; i++) { + Object factory = constructor.newInstance((Path) null); + init.invoke(factory, new Object[]{null}); + getTrustManagers.invoke(factory); + } + })); } - - return levels; } private static void restoreProperty(String key, String value) { @@ -141,6 +171,10 @@ private List recordSystemSummary() throws Exception { factory.engineGetTrustManagers(); }); + return filterSummary(records); + } + + private static List filterSummary(List records) { List summary = new ArrayList<>(); for (LogRecord record : records) { String message = record.getMessage(); diff --git a/src/test/java/org/cloudfoundry/security/TrustStoreDiagnosticsTest.java b/src/test/java/org/cloudfoundry/security/TrustStoreDiagnosticsTest.java index 18a9a43..f578e61 100644 --- a/src/test/java/org/cloudfoundry/security/TrustStoreDiagnosticsTest.java +++ b/src/test/java/org/cloudfoundry/security/TrustStoreDiagnosticsTest.java @@ -64,7 +64,7 @@ public void systemTrustStoreReportsConfiguredPropertyAndType() { System.setProperty("javax.net.ssl.trustStoreType", "PKCS12"); assertThat(TrustStoreDiagnostics.systemTrustStore()) - .isEqualTo("default JVM configuration (javax.net.ssl.trustStore=/custom/store.p12, javax.net.ssl.trustStoreType=PKCS12)"); + .isEqualTo("javax.net.ssl.trustStore=/custom/store.p12, javax.net.ssl.trustStoreType=PKCS12"); } @Test @@ -72,13 +72,20 @@ public void systemTrustStoreReportsValuesAsSetWithoutInterpreting() { System.setProperty("javax.net.ssl.trustStore", "NONE"); assertThat(TrustStoreDiagnostics.systemTrustStore()) - .isEqualTo("default JVM configuration (javax.net.ssl.trustStore=NONE, javax.net.ssl.trustStoreType=not set)"); + .isEqualTo("javax.net.ssl.trustStore=NONE, javax.net.ssl.trustStoreType=not set"); } @Test - public void systemTrustStoreReportsUnsetProperty() { + public void systemTrustStoreReportsTypeEvenWhenStoreIsUnset() { + System.setProperty("javax.net.ssl.trustStoreType", "PKCS11"); + assertThat(TrustStoreDiagnostics.systemTrustStore()) - .isEqualTo("default JVM configuration (javax.net.ssl.trustStore not set)"); + .isEqualTo("javax.net.ssl.trustStore=not set, javax.net.ssl.trustStoreType=PKCS11"); + } + + @Test + public void systemTrustStoreIsNullWhenDefault() { + assertThat(TrustStoreDiagnostics.systemTrustStore()).isNull(); } @Test