diff --git a/docs/doctoring/target-connector-diagnostic-confidentiality.md b/docs/doctoring/target-connector-diagnostic-confidentiality.md new file mode 100644 index 00000000..b108a528 --- /dev/null +++ b/docs/doctoring/target-connector-diagnostic-confidentiality.md @@ -0,0 +1,41 @@ +# Target connector diagnostic confidentiality + +## Status and scope + +This doctoring note records the security boundary implemented by the active target-connector lifecycle logging change. It does not make an active pull request shipped product truth. The production rule is narrow: ordinary logs may retain a bounded connector lifecycle outcome and connector ID, but they must not serialize third-party exception objects or provider diagnostics that can carry a connection string, token, request fragment, account identifier, storage path, or other deployment-sensitive data. + +The rule maps directly to **CWE-532, Insertion of Sensitive Information into Log File**. MITRE describes sensitive values written to logs as a confidentiality weakness because log storage is commonly a less-protected secondary disclosure path. The OWASP Logging Cheat Sheet likewise requires deliberate exclusion or sanitization of sensitive event data and protection of collected logs against unauthorized access or misuse. + +## Causal error retention without diagnostic publication + +Failing connector cleanup after a failed `open(...)` remains attached to the original in-process failure as a **suppressed exception**. This preserves causal information for an authorized caller or debugger that already possesses the exception object without copying provider exception text into routine logs. Shutdown remains best-effort: a failing connector close is classified, the dispatcher continues closing other connectors, and the open-state bookkeeping is cleared as before. + +Routine observability therefore records only finite lifecycle classifications such as `Failed to clean up target connector after open failure` or `Failed to close target connector`, plus the bounded **connector ID**. It does not include provider exception messages or stack traces. The connector ID is operational metadata, not permission to add arbitrary provider configuration to the log record. + +## Privacy and operational policy + +This is data minimization and **purpose-bound** observability, not blanket masking. Connector implementations still receive the real endpoint, credential, payload, and business data required to perform their authorized work. Those values remain available only at the execution boundary that needs them. Ordinary application logs are a different purpose and therefore receive the minimum information needed to detect and count lifecycle failure. + +Do not replace this boundary with regex-only masking. Provider exception formats are not a stable schema, and new drivers can embed sensitive values in previously unseen text. If deeper diagnostics are required during an incident, use explicitly authorized, access-controlled diagnostic tooling and bounded retention rather than widening default logs. + +Recommended operator evidence is finite-cardinality counts of lifecycle outcomes by supported connector ID and deployment version. Avoid raw principal, credential, connection string, request payload, SQL, provider exception text, or arbitrary target identifiers in ordinary telemetry. + +## Verification contract + +Regression tests must prove that: + +- cleanup and close failures do not export provider exception messages, class names, or stack traces to ordinary logs; +- the original failed-open exception still carries cleanup failure causality through its suppressed-exception list; +- shutdown close failure remains best-effort and leaves dispatcher state coherent; +- the stable lifecycle classification and bounded connector ID remain observable; and +- no security remediation weakens connector validation, error propagation, or cleanup ordering merely to make logs quiet. + +These checks are necessary behavioral evidence, but current pull-request workflow results must still be classified by the revision actually executed. A synthetic merge preview does not become literal-source evidence merely because the tests pass. + +## References — APA 7 + +MITRE. (2026). *CWE-532: Insertion of sensitive information into log file (Version 4.20).* Common Weakness Enumeration. https://cwe.mitre.org/data/definitions/532.html + +OWASP Foundation. (2026). *Logging cheat sheet.* OWASP Cheat Sheet Series. https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html + +OWASP Foundation. (2025). *A09:2025 Security logging and alerting failures.* OWASP Top 10. https://owasp.org/Top10/2025/A09_2025-Security_Logging_and_Alerting_Failures/ diff --git a/etl-service/src/main/java/com/xtrmetl/etl/connector/TargetConnectorDispatcher.java b/etl-service/src/main/java/com/xtrmetl/etl/connector/TargetConnectorDispatcher.java index 72ddada9..29186cf0 100644 --- a/etl-service/src/main/java/com/xtrmetl/etl/connector/TargetConnectorDispatcher.java +++ b/etl-service/src/main/java/com/xtrmetl/etl/connector/TargetConnectorDispatcher.java @@ -186,11 +186,7 @@ private void ensureOpen( connector.close(); } catch (RuntimeException cleanupFailure) { openFailure.addSuppressed(cleanupFailure); - log.warn( - "Failed to clean up target connector after open failure id={}", - connectorId, - cleanupFailure - ); + log.warn("Failed to clean up target connector after open failure id={}", connectorId); } throw openFailure; } @@ -232,7 +228,7 @@ void closeOpenedConnectors() { connector.close(); log.info("Closed target connector id={}", connectorId); } catch (RuntimeException exception) { - log.error("Failed to close target connector id={}", connectorId, exception); + log.error("Failed to close target connector id={}", connectorId); } } } diff --git a/etl-service/src/test/java/com/xtrmetl/etl/connector/TargetConnectorDiagnosticConfidentialityDocumentationTest.java b/etl-service/src/test/java/com/xtrmetl/etl/connector/TargetConnectorDiagnosticConfidentialityDocumentationTest.java new file mode 100644 index 00000000..84f83a98 --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/connector/TargetConnectorDiagnosticConfidentialityDocumentationTest.java @@ -0,0 +1,58 @@ +package com.xtrmetl.etl.connector; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Keeps target-connector diagnostic-confidentiality guidance tied to current security references. + */ +class TargetConnectorDiagnosticConfidentialityDocumentationTest { + + @Test + void doctoringDocumentsProviderExceptionMinimizationAndCausalErrorRetention() throws IOException { + Path doctoring = findProjectRoot().resolve( + "docs/doctoring/target-connector-diagnostic-confidentiality.md" + ); + assertTrue(Files.isRegularFile(doctoring), + "target connector diagnostic-confidentiality doctoring must be checked in"); + + String body = Files.readString(doctoring, StandardCharsets.UTF_8); + assertTrue(body.contains("CWE-532"), "doctoring must map log exposure to CWE-532"); + assertTrue(body.contains("OWASP Logging Cheat Sheet"), + "doctoring must cite current OWASP logging guidance"); + assertTrue(body.contains("suppressed exception"), + "doctoring must preserve in-process suppressed-exception causality"); + assertTrue(body.contains("connector ID"), + "doctoring must preserve bounded connector lifecycle classification"); + assertTrue(body.contains("connection string") || body.contains("connection strings"), + "doctoring must identify provider connection strings as sensitive diagnostics"); + assertTrue(body.contains("purpose-bound") || body.contains("purpose limitation"), + "doctoring must distinguish minimization from blanket masking"); + assertTrue(body.contains("APA 7"), "doctoring must identify the reference style"); + } + + private static Path findProjectRoot() { + Path current = Paths.get(System.getProperty("user.dir")).toAbsolutePath(); + Path lastPom = null; + while (current != null) { + if (Files.exists(current.resolve(".git"))) { + return current; + } + if (Files.exists(current.resolve("pom.xml"))) { + lastPom = current; + } + current = current.getParent(); + } + if (lastPom != null) { + return lastPom; + } + throw new IllegalStateException("project root not found"); + } +} diff --git a/etl-service/src/test/java/com/xtrmetl/etl/connector/TargetConnectorDispatcherLoggingTest.java b/etl-service/src/test/java/com/xtrmetl/etl/connector/TargetConnectorDispatcherLoggingTest.java new file mode 100644 index 00000000..50ef8fa3 --- /dev/null +++ b/etl-service/src/test/java/com/xtrmetl/etl/connector/TargetConnectorDispatcherLoggingTest.java @@ -0,0 +1,161 @@ +package com.xtrmetl.etl.connector; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that target-connector lifecycle logs retain finite outcome classification without + * serializing third-party exception diagnostics. + */ +@ExtendWith(OutputCaptureExtension.class) +class TargetConnectorDispatcherLoggingTest { + + @Test + void failedOpenCleanupDoesNotLogProviderDiagnostics(CapturedOutput output) { + String openSecret = "https://warehouse.example/sql?token=open-secret-8472"; + String cleanupSecret = "cleanup-secret-8472"; + RuntimeException openFailure = new IllegalStateException(openSecret); + RuntimeException cleanupFailure = new IllegalArgumentException(cleanupSecret); + FailingLifecycleConnector connector = new FailingLifecycleConnector(openFailure, cleanupFailure, false); + TargetConnectorDispatcher dispatcher = dispatcher(connector); + + RuntimeException thrown = assertThrows( + RuntimeException.class, + () -> dispatcher.dispatch("databricks", List.of()) + ); + + assertSame(openFailure, thrown); + assertEquals(1, thrown.getSuppressed().length); + assertSame(cleanupFailure, thrown.getSuppressed()[0]); + assertSafeLogs( + output, + "Failed to clean up target connector after open failure id=databricks", + cleanupSecret, + "IllegalArgumentException", + "TargetConnectorDispatcherLoggingTest" + ); + } + + @Test + void shutdownCloseFailureDoesNotLogProviderDiagnosticsAndRemainsBestEffort(CapturedOutput output) { + String closeSecret = "jdbc:vendor://internal.example/prod?password=close-secret-8472"; + FailingLifecycleConnector connector = new FailingLifecycleConnector( + null, + new IllegalStateException(closeSecret), + true + ); + TargetConnectorDispatcher dispatcher = dispatcher(connector); + + dispatcher.dispatch("databricks", List.of()); + dispatcher.closeOpenedConnectors(); + + assertEquals(List.of("open", "write", "close"), connector.events); + assertSafeLogs( + output, + "Failed to close target connector id=databricks", + closeSecret, + "IllegalStateException", + "TargetConnectorDispatcherLoggingTest" + ); + assertFalse(Boolean.TRUE.equals(dispatcher.catalog().stream() + .filter(row -> "databricks".equals(row.get("id"))) + .findFirst() + .orElseThrow() + .get("opened"))); + } + + private static TargetConnectorDispatcher dispatcher(TargetConnector connector) { + TargetConnectorRegistry registry = new TargetConnectorRegistry(); + registry.register(connector); + return new TargetConnectorDispatcher(registry, enabledDatabricksProperties()); + } + + private static ConnectorProperties enabledDatabricksProperties() { + ConnectorProperties properties = new ConnectorProperties(); + properties.getDatabricks().setEnabled(true); + properties.getDatabricks().setHost("host"); + properties.getDatabricks().setHttpPath("/sql"); + properties.getDatabricks().setToken("token"); + properties.getDatabricks().setCatalog("catalog"); + properties.getDatabricks().setSchema("schema"); + properties.getDatabricks().setTable("table_name"); + return properties; + } + + private static void assertSafeLogs(CapturedOutput output, String expected, String... forbidden) { + String logs = output.getOut() + output.getErr(); + assertTrue(logs.contains(expected)); + for (String value : forbidden) { + assertFalse(logs.contains(value), () -> "Log output exposed forbidden value: " + value); + } + } + + private static final class FailingLifecycleConnector implements TargetConnector { + private final RuntimeException openFailure; + private final RuntimeException closeFailure; + private final boolean openSucceeds; + private final java.util.ArrayList events = new java.util.ArrayList<>(); + + private FailingLifecycleConnector( + RuntimeException openFailure, + RuntimeException closeFailure, + boolean openSucceeds + ) { + this.openFailure = openFailure; + this.closeFailure = closeFailure; + this.openSucceeds = openSucceeds; + } + + @Override + public String id() { + return "databricks"; + } + + @Override + public String displayName() { + return "Failing lifecycle connector"; + } + + @Override + public ConnectorStatus status() { + return ConnectorStatus.SUPPORTED; + } + + @Override + public void validate(Map config) { + // This fake intentionally accepts the validated dispatcher fixture configuration. + } + + @Override + public void open(Map config) { + events.add("open"); + if (!openSucceeds) { + throw openFailure; + } + } + + @Override + public void write(List batch) { + events.add("write"); + } + + @Override + public void close() { + events.add("close"); + if (closeFailure != null) { + throw closeFailure; + } + } + } +}