From af689030c521057203fac4e13cc48ae83d7c5db2 Mon Sep 17 00:00:00 2001 From: Philip Z Date: Sun, 27 Sep 2026 21:04:28 +0800 Subject: [PATCH] fix: redact runtime and portable logs; prepare 1.551-RELEASE (#184) Signed-off-by: Philip Z --- README.md | 22 +++++ examples/business-id-runtime/pom.xml | 2 +- examples/conformance/lib/pom.xml | 2 +- examples/conformance/pom.xml | 2 +- .../order-management/java-app-console/pom.xml | 2 +- .../java-lib-core/lib/pom.xml | 2 +- examples/round-trip-reference-runtime/pom.xml | 2 +- examples/school-management/lib/pom.xml | 2 +- examples/school-management/pom.xml | 2 +- examples/security-foundations/pom.xml | 2 +- pom.xml | 2 +- releases/1.551-RELEASE.md | 34 +++++++ teaql-android/pom.xml | 2 +- teaql-business-id-jdbc/pom.xml | 2 +- teaql-cloud/pom.xml | 2 +- teaql-context-runtime-tools/pom.xml | 2 +- teaql-core/pom.xml | 2 +- teaql-data-service-sql/pom.xml | 2 +- teaql-db2/pom.xml | 2 +- teaql-dm8/pom.xml | 2 +- teaql-duckdb/pom.xml | 2 +- teaql-dynamic-fields-api/pom.xml | 2 +- teaql-dynamic-fields-jdbc/pom.xml | 2 +- teaql-hana/pom.xml | 2 +- teaql-jackson/pom.xml | 2 +- teaql-mssql/pom.xml | 2 +- teaql-mysql/pom.xml | 2 +- teaql-opentelemetry/pom.xml | 2 +- teaql-oracle/pom.xml | 2 +- teaql-postgres/pom.xml | 2 +- teaql-provider-jdbc/pom.xml | 2 +- teaql-provider-spring-jdbc/pom.xml | 2 +- teaql-query-json/pom.xml | 2 +- teaql-redis/pom.xml | 2 +- teaql-runtime-log/pom.xml | 2 +- .../runtime/log/HumanReaderFormatter.java | 1 + .../runtime/log/JsonReaderFormatter.java | 1 + .../java/io/teaql/runtime/log/LogManager.java | 26 +++-- .../runtime/log/ExecutionLogPrivacyTest.java | 11 +-- teaql-runtime/pom.xml | 2 +- .../runtime/DefaultTextRuntimeLogSink.java | 1 + .../java/io/teaql/runtime/LogPrivacy.java | 94 ++++++++++++++++++ ...SensitiveDiagnosticTextRuntimeLogSink.java | 3 +- .../java/io/teaql/runtime/TeaQLRuntime.java | 23 +++-- .../java/io/teaql/runtime/LogPrivacyTest.java | 98 +++++++++++++++++++ .../io/teaql/runtime/TeaQLRuntimeTest.java | 4 +- .../teaql/runtime/TextRuntimeLogSinkTest.java | 8 +- teaql-snowflake/pom.xml | 2 +- teaql-sql-portable/pom.xml | 2 +- .../sql/portable/PortableSQLRepository.java | 6 +- .../sql/portable/PortableLogPrivacyTest.java | 23 +++++ teaql-sqlite/pom.xml | 2 +- .../teaql/sqlite/SqliteIntegrationTest.java | 61 ++++++++++-- teaql-tfp-endpoint/pom.xml | 2 +- teaql-tool-http/pom.xml | 2 +- teaql-utils-json/pom.xml | 2 +- teaql-utils-reflection/pom.xml | 2 +- teaql-utils-spring/pom.xml | 2 +- teaql-utils/pom.xml | 2 +- 59 files changed, 423 insertions(+), 79 deletions(-) create mode 100644 releases/1.551-RELEASE.md create mode 100644 teaql-runtime/src/main/java/io/teaql/runtime/LogPrivacy.java create mode 100644 teaql-runtime/src/test/java/io/teaql/runtime/LogPrivacyTest.java create mode 100644 teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/PortableLogPrivacyTest.java diff --git a/README.md b/README.md index 62200bfb..393daa37 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,27 @@ # TeaQL Java +## Sensitive log data + +Runtime diagnostic logs redact payload values by default, before delivery to +file, console, buffers, or custom logging sinks. Selecting a diagnostic sink +alone does not authorize plaintext. For controlled troubleshooting only: + +```bash +export TEAQL_ALLOW_SENSITIVE_PLAINTEXT_LOGS=I_UNDERSTAND_SENSITIVE_DATA_MAY_BE_WRITTEN_TO_DISK +``` + +Only this exact value enables plaintext permission; empty values, `true`, and +whitespace variants do not. Enabling it emits a warning. Credential-classified +fields remain redacted. The flag does not force every sink to expose values. +SQL without reliable field/literal provenance may be suppressed and marked +`NOT REPLAYABLE`. Execution parameters and persisted business data are unchanged. + +Do not put sensitive data in free-text comments or purpose declarations. +TeaQL cannot govern arbitrary application prints or independent driver loggers; +configure those separately. This setting does not erase older plaintext files. +Restrict access and retention when using plaintext diagnostics, then unset the +variable and restart processes when troubleshooting is complete. + [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13612/badge)](https://www.bestpractices.dev/projects/13612) TeaQL Java is the Java runtime for TeaQL domain applications. It provides a diff --git a/examples/business-id-runtime/pom.xml b/examples/business-id-runtime/pom.xml index f9ed9fc5..04e086f5 100644 --- a/examples/business-id-runtime/pom.xml +++ b/examples/business-id-runtime/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE ../../pom.xml teaql-business-id-example diff --git a/examples/conformance/lib/pom.xml b/examples/conformance/lib/pom.xml index 50d78872..2d6dd0df 100644 --- a/examples/conformance/lib/pom.xml +++ b/examples/conformance/lib/pom.xml @@ -12,7 +12,7 @@ 21 4.1.1 - 1.550-RELEASE + 1.551-RELEASE UTF-8 true diff --git a/examples/conformance/pom.xml b/examples/conformance/pom.xml index e22b76f1..3670af08 100644 --- a/examples/conformance/pom.xml +++ b/examples/conformance/pom.xml @@ -16,7 +16,7 @@ 1.0.0 UTF-8 true - 1.550-RELEASE + 1.551-RELEASE diff --git a/examples/order-management/java-app-console/pom.xml b/examples/order-management/java-app-console/pom.xml index 29d443ed..0145cb16 100644 --- a/examples/order-management/java-app-console/pom.xml +++ b/examples/order-management/java-app-console/pom.xml @@ -11,7 +11,7 @@ 21 UTF-8 - 1.550-RELEASE + 1.551-RELEASE diff --git a/examples/order-management/java-lib-core/lib/pom.xml b/examples/order-management/java-lib-core/lib/pom.xml index 99f4b6bb..414c9304 100644 --- a/examples/order-management/java-lib-core/lib/pom.xml +++ b/examples/order-management/java-lib-core/lib/pom.xml @@ -12,7 +12,7 @@ 21 4.1.1 - 1.550-RELEASE + 1.551-RELEASE UTF-8 true diff --git a/examples/round-trip-reference-runtime/pom.xml b/examples/round-trip-reference-runtime/pom.xml index a46f4920..98e556b8 100644 --- a/examples/round-trip-reference-runtime/pom.xml +++ b/examples/round-trip-reference-runtime/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE ../../pom.xml teaql-round-trip-reference-example diff --git a/examples/school-management/lib/pom.xml b/examples/school-management/lib/pom.xml index 9a310346..96c3a4a2 100644 --- a/examples/school-management/lib/pom.xml +++ b/examples/school-management/lib/pom.xml @@ -12,7 +12,7 @@ 21 4.1.1 - 1.550-RELEASE + 1.551-RELEASE UTF-8 true diff --git a/examples/school-management/pom.xml b/examples/school-management/pom.xml index 3766edc6..bd325b4b 100644 --- a/examples/school-management/pom.xml +++ b/examples/school-management/pom.xml @@ -16,7 +16,7 @@ 0.0.1-SNAPSHOT UTF-8 true - 1.550-RELEASE + 1.551-RELEASE diff --git a/examples/security-foundations/pom.xml b/examples/security-foundations/pom.xml index 14a72b17..da1cfae2 100644 --- a/examples/security-foundations/pom.xml +++ b/examples/security-foundations/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE ../../pom.xml teaql-security-foundations-example diff --git a/pom.xml b/pom.xml index cc74ff52..44059240 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE pom teaql-java-parent diff --git a/releases/1.551-RELEASE.md b/releases/1.551-RELEASE.md new file mode 100644 index 00000000..b4e5923c --- /dev/null +++ b/releases/1.551-RELEASE.md @@ -0,0 +1,34 @@ +# 1.551-RELEASE: default log privacy + +Tracking: https://github.com/teaql/teaql-java/issues/184 +Cross-runtime contract: https://github.com/teaql/teaql-conformance/issues/58 + +## Behavior and migration + +Runtime-controlled SQL and audit logging now redact sensitive payloads before +normal, custom, buffered and file destinations. Execution values are unchanged. +Selecting a diagnostic sink alone no longer authorizes plaintext output. + +For intentional plaintext debugging only, set this exact acknowledgement: + +```sh +export TEAQL_ALLOW_SENSITIVE_PLAINTEXT_LOGS=I_UNDERSTAND_SENSITIVE_DATA_MAY_BE_WRITTEN_TO_DISK +``` + +Invalid, empty or truthy shortcut values do not enable it. Enabling it emits a +value-free warning. Credential-classified fields remain hidden. The switch grants +permission; it does not force every sink to reveal values. Restart after changing +the environment. Previously written files are not erased. + +Without reliable parameter/field provenance, SQL logs use conservative redaction; +SQL may be labeled non-replayable. Operations, timing and safe intent remain. +Do not put unrelated secrets in comments/purpose, or print raw business events. +Arbitrary application and third-party driver logs are outside this boundary. + +## Verification + +Local runtime tests and `bash scripts/verify-examples.sh` passed before release +preparation. Retained real SQLite CRUD/failure canaries assert unchanged persisted +values and absence of sensitive markers in runtime log destinations. Exact/invalid +environment gates and credential behavior have separate regression fixtures. +Published-package verification is a separate gate, not implied by local results. diff --git a/teaql-android/pom.xml b/teaql-android/pom.xml index 3e5e2578..64c09eb5 100644 --- a/teaql-android/pom.xml +++ b/teaql-android/pom.xml @@ -7,7 +7,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE teaql-android diff --git a/teaql-business-id-jdbc/pom.xml b/teaql-business-id-jdbc/pom.xml index 9d6dcc8e..b9f07d84 100644 --- a/teaql-business-id-jdbc/pom.xml +++ b/teaql-business-id-jdbc/pom.xml @@ -7,7 +7,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE ../pom.xml diff --git a/teaql-cloud/pom.xml b/teaql-cloud/pom.xml index c5c18ba0..a73fa662 100644 --- a/teaql-cloud/pom.xml +++ b/teaql-cloud/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE teaql-cloud teaql-cloud diff --git a/teaql-context-runtime-tools/pom.xml b/teaql-context-runtime-tools/pom.xml index 5d7e4ead..1d7992f2 100644 --- a/teaql-context-runtime-tools/pom.xml +++ b/teaql-context-runtime-tools/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE teaql-context-runtime-tools diff --git a/teaql-core/pom.xml b/teaql-core/pom.xml index 11f21672..0fb136fe 100644 --- a/teaql-core/pom.xml +++ b/teaql-core/pom.xml @@ -7,7 +7,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE ../pom.xml diff --git a/teaql-data-service-sql/pom.xml b/teaql-data-service-sql/pom.xml index e8e4cd62..be5e6073 100644 --- a/teaql-data-service-sql/pom.xml +++ b/teaql-data-service-sql/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE ../pom.xml diff --git a/teaql-db2/pom.xml b/teaql-db2/pom.xml index 3fb7385f..4a797539 100644 --- a/teaql-db2/pom.xml +++ b/teaql-db2/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE teaql-db2 teaql-db2 diff --git a/teaql-dm8/pom.xml b/teaql-dm8/pom.xml index 5c1280c8..9d895b47 100644 --- a/teaql-dm8/pom.xml +++ b/teaql-dm8/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE teaql-dm8 teaql-dm8 diff --git a/teaql-duckdb/pom.xml b/teaql-duckdb/pom.xml index 275f3f36..3c111ac3 100644 --- a/teaql-duckdb/pom.xml +++ b/teaql-duckdb/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE teaql-duckdb teaql-duckdb diff --git a/teaql-dynamic-fields-api/pom.xml b/teaql-dynamic-fields-api/pom.xml index c3cd66a7..7632ce30 100644 --- a/teaql-dynamic-fields-api/pom.xml +++ b/teaql-dynamic-fields-api/pom.xml @@ -7,7 +7,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE ../pom.xml diff --git a/teaql-dynamic-fields-jdbc/pom.xml b/teaql-dynamic-fields-jdbc/pom.xml index 5c031aa9..879237e4 100644 --- a/teaql-dynamic-fields-jdbc/pom.xml +++ b/teaql-dynamic-fields-jdbc/pom.xml @@ -7,7 +7,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE ../pom.xml diff --git a/teaql-hana/pom.xml b/teaql-hana/pom.xml index 43485311..93fb5bd7 100644 --- a/teaql-hana/pom.xml +++ b/teaql-hana/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE teaql-hana teaql-hana diff --git a/teaql-jackson/pom.xml b/teaql-jackson/pom.xml index c4abdec9..1a826a5d 100644 --- a/teaql-jackson/pom.xml +++ b/teaql-jackson/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE teaql-jackson diff --git a/teaql-mssql/pom.xml b/teaql-mssql/pom.xml index c8d41f6c..f0db014f 100644 --- a/teaql-mssql/pom.xml +++ b/teaql-mssql/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE teaql-mssql teaql-mssql diff --git a/teaql-mysql/pom.xml b/teaql-mysql/pom.xml index 01cb2be2..8dcd11ed 100644 --- a/teaql-mysql/pom.xml +++ b/teaql-mysql/pom.xml @@ -7,7 +7,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE teaql-mysql diff --git a/teaql-opentelemetry/pom.xml b/teaql-opentelemetry/pom.xml index 25905c44..b364a210 100644 --- a/teaql-opentelemetry/pom.xml +++ b/teaql-opentelemetry/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE teaql-opentelemetry diff --git a/teaql-oracle/pom.xml b/teaql-oracle/pom.xml index 03a6eb6d..79ba329e 100644 --- a/teaql-oracle/pom.xml +++ b/teaql-oracle/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE teaql-oracle teaql-oracle diff --git a/teaql-postgres/pom.xml b/teaql-postgres/pom.xml index f9959b3f..c0e0d915 100644 --- a/teaql-postgres/pom.xml +++ b/teaql-postgres/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE teaql-postgres teaql-postgres diff --git a/teaql-provider-jdbc/pom.xml b/teaql-provider-jdbc/pom.xml index b3929d9d..c767a936 100644 --- a/teaql-provider-jdbc/pom.xml +++ b/teaql-provider-jdbc/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE ../pom.xml diff --git a/teaql-provider-spring-jdbc/pom.xml b/teaql-provider-spring-jdbc/pom.xml index faaa3022..2fc85547 100644 --- a/teaql-provider-spring-jdbc/pom.xml +++ b/teaql-provider-spring-jdbc/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE ../pom.xml diff --git a/teaql-query-json/pom.xml b/teaql-query-json/pom.xml index 8d59ee0b..b2abd1a9 100644 --- a/teaql-query-json/pom.xml +++ b/teaql-query-json/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE teaql-query-json diff --git a/teaql-redis/pom.xml b/teaql-redis/pom.xml index e61e466f..00bab25e 100644 --- a/teaql-redis/pom.xml +++ b/teaql-redis/pom.xml @@ -7,7 +7,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE ../pom.xml diff --git a/teaql-runtime-log/pom.xml b/teaql-runtime-log/pom.xml index 9a8ea988..4443c0f3 100644 --- a/teaql-runtime-log/pom.xml +++ b/teaql-runtime-log/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE teaql-runtime-log diff --git a/teaql-runtime-log/src/main/java/io/teaql/runtime/log/HumanReaderFormatter.java b/teaql-runtime-log/src/main/java/io/teaql/runtime/log/HumanReaderFormatter.java index 921f774f..f780143b 100644 --- a/teaql-runtime-log/src/main/java/io/teaql/runtime/log/HumanReaderFormatter.java +++ b/teaql-runtime-log/src/main/java/io/teaql/runtime/log/HumanReaderFormatter.java @@ -23,6 +23,7 @@ private String formatTraceChain(List traceChain) { @Override public String formatExecutionLog(io.teaql.core.ExecutionMetadata metadata) { + metadata = io.teaql.runtime.LogPrivacy.sql(metadata, io.teaql.runtime.LogPrivacy.plaintextEnabled()); String ts = LocalDateTime.now().format(TS_FORMATTER); String traceStr = formatTraceChain(metadata.getTraceChain()); String traceDisplay = traceStr.isEmpty() ? "" : " - [" + traceStr + "]"; diff --git a/teaql-runtime-log/src/main/java/io/teaql/runtime/log/JsonReaderFormatter.java b/teaql-runtime-log/src/main/java/io/teaql/runtime/log/JsonReaderFormatter.java index 15cf5b38..e437b081 100644 --- a/teaql-runtime-log/src/main/java/io/teaql/runtime/log/JsonReaderFormatter.java +++ b/teaql-runtime-log/src/main/java/io/teaql/runtime/log/JsonReaderFormatter.java @@ -23,6 +23,7 @@ private String escapeJson(String text) { @Override public String formatExecutionLog(io.teaql.core.ExecutionMetadata metadata) { + metadata = io.teaql.runtime.LogPrivacy.sql(metadata, io.teaql.runtime.LogPrivacy.plaintextEnabled()); String execution = String.format("{\"type\":\"EXEC_LOG\",\"tracePath\":%s,\"backend\":\"%s\",\"operation\":\"%s\",\"comment\":\"%s\",\"purpose\":\"%s\",\"auditReason\":\"%s\",\"elapsedUs\":%d,\"resultCount\":%s,\"affectedRows\":%s,\"summary\":\"%s\",\"parameterizedSQL\":\"%s\"", formatTraceChain(metadata.getTraceChain()), escapeJson(metadata.getBackend()), diff --git a/teaql-runtime-log/src/main/java/io/teaql/runtime/log/LogManager.java b/teaql-runtime-log/src/main/java/io/teaql/runtime/log/LogManager.java index f1de4559..173e6ea6 100644 --- a/teaql-runtime-log/src/main/java/io/teaql/runtime/log/LogManager.java +++ b/teaql-runtime-log/src/main/java/io/teaql/runtime/log/LogManager.java @@ -128,7 +128,7 @@ private void initFileChannel() { this.currentSize.set(this.currentChannel.size()); calculateNextMidnight(); } catch (Exception e) { - System.err.println("TeaQL LogManager Failed to initialize file channel: " + e.getMessage()); + System.err.println("TeaQL LogManager Failed to initialize file channel: " + e.getClass().getSimpleName()); } } } @@ -142,7 +142,7 @@ private void processQueue() { Thread.currentThread().interrupt(); break; } catch (Exception e) { - System.err.println("TeaQL LogManager Exception in worker thread: " + e.getMessage()); + System.err.println("TeaQL LogManager Exception in worker thread: " + e.getClass().getSimpleName()); } } } @@ -170,7 +170,7 @@ private void writeHeaderIfNeeded() { currentSize.addAndGet(bytes.length); } } catch (Exception e) { - System.err.println("TeaQL LogManager Failed to write header: " + e.getMessage()); + System.err.println("TeaQL LogManager Failed to write header: " + e.getClass().getSimpleName()); } finally { headerWritten = true; } @@ -217,7 +217,7 @@ private void syncWrite(String content, CustomLogSink customSink) { currentChannel.write(ByteBuffer.wrap(bytes)); currentSize.addAndGet(bytes.length); } catch (Exception e) { - System.err.println("TeaQL LogManager Failed to write to log file: " + e.getMessage()); + System.err.println("TeaQL LogManager Failed to write to log file: " + e.getClass().getSimpleName()); } } } @@ -244,7 +244,7 @@ private void rotateLogFile() { } } catch (Exception e) { - System.err.println("TeaQL LogManager Failed to rotate log file: " + e.getMessage()); + System.err.println("TeaQL LogManager Failed to rotate log file: " + e.getClass().getSimpleName()); } } @@ -263,7 +263,7 @@ private void compressAsync(File source) { gos.finish(); source.delete(); } catch (Exception e) { - System.err.println("TeaQL LogManager Failed to compress log file: " + e.getMessage()); + System.err.println("TeaQL LogManager Failed to compress log file: " + e.getClass().getSimpleName()); } }); } @@ -294,6 +294,7 @@ public void writeExecutionLog(io.teaql.core.UserContext context, io.teaql.core.E if (!LogConfig.getInstance().shouldLogSql(metadata.getParameterizedQuery())) { return; } + metadata = io.teaql.runtime.LogPrivacy.sql(metadata, io.teaql.runtime.LogPrivacy.plaintextEnabled()); String content = LogFormatterFactory.getFormatter().formatExecutionLog(metadata); CustomLogSink customSink = resolveCustomSink(context); asyncWrite(content, customSink); @@ -313,6 +314,19 @@ public void writeAuditLog(io.teaql.core.UserContext context, List tra if (!LogConfig.getInstance().shouldLogAudit(event.getEntityType())) { return; } + boolean allow = io.teaql.runtime.LogPrivacy.plaintextEnabled(); + java.util.List secrets = new java.util.ArrayList<>(); + java.util.List safeChanges = event.getChanges().stream().map(change -> { + if (allow && !io.teaql.runtime.LogPrivacy.credential(change.getField()) + && !io.teaql.runtime.LogPrivacy.hasCredentials(change.getOldValue()) + && !io.teaql.runtime.LogPrivacy.hasCredentials(change.getNewValue())) return change; + secrets.add(change.getOldValue()); secrets.add(change.getNewValue()); + return new FieldChange(change.getField(), + change.getOldValue() == null ? null : io.teaql.runtime.LogPrivacy.REDACTED, + change.getNewValue() == null ? null : io.teaql.runtime.LogPrivacy.REDACTED); + }).toList(); + event = new AuditEvent(event.getEntityType(), event.getEntityId(), event.getMutationKind(), safeChanges); + traceChain = io.teaql.runtime.LogPrivacy.trace(traceChain, secrets); String content = LogFormatterFactory.getFormatter().formatAuditLog(traceChain, event); CustomLogSink customSink = resolveCustomSink(context); asyncWrite(content, customSink); diff --git a/teaql-runtime-log/src/test/java/io/teaql/runtime/log/ExecutionLogPrivacyTest.java b/teaql-runtime-log/src/test/java/io/teaql/runtime/log/ExecutionLogPrivacyTest.java index 16a79353..6322de76 100644 --- a/teaql-runtime-log/src/test/java/io/teaql/runtime/log/ExecutionLogPrivacyTest.java +++ b/teaql-runtime-log/src/test/java/io/teaql/runtime/log/ExecutionLogPrivacyTest.java @@ -33,7 +33,7 @@ public void safeFormattersOmitValueBearingFields() { } @Test - public void explicitDiagnosticMetadataRetainsCopyPasteSql() { + public void diagnosticMetadataAloneDoesNotAuthorizePlaintext() { ExecutionMetadata metadata = metadata(); metadata.setParameters(List.of(SECRET)); metadata.setDebugQuery("SELECT id FROM customer_data WHERE name = '" + SECRET + "'"); @@ -41,11 +41,10 @@ public void explicitDiagnosticMetadataRetainsCopyPasteSql() { String human = new HumanReaderFormatter().formatExecutionLog(metadata); String json = new JsonReaderFormatter().formatExecutionLog(metadata); - assertTrue(human.contains("params=[" + SECRET + "]")); - assertTrue(human.contains("Debug SQL: SELECT id FROM customer_data WHERE name = '" + SECRET + "'")); - assertTrue(json.contains("\"parameters\"")); - assertTrue(json.contains("\"debugSQL\"")); - assertTrue(json.contains(SECRET)); + assertFalse(human.contains(SECRET)); + assertFalse(json.contains(SECRET)); + assertFalse(json.contains("\"parameters\"")); + assertFalse(json.contains("\"debugSQL\"")); } @Test diff --git a/teaql-runtime/pom.xml b/teaql-runtime/pom.xml index ed6d2eca..238a2aaa 100644 --- a/teaql-runtime/pom.xml +++ b/teaql-runtime/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE teaql-runtime diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/DefaultTextRuntimeLogSink.java b/teaql-runtime/src/main/java/io/teaql/runtime/DefaultTextRuntimeLogSink.java index 844547a5..33c3fbfa 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/DefaultTextRuntimeLogSink.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/DefaultTextRuntimeLogSink.java @@ -26,6 +26,7 @@ public boolean requiresSensitiveSqlData() { @Override public void writeExecutionLog(UserContext context, ExecutionMetadata metadata) { + metadata = LogPrivacy.sql(metadata, false); output.printf( "[TeaQL SQL][%s][%dus] %s comment=%s purpose=%s auditReason=%s tracePath=%s%n" + "Parameterized SQL: %s%n", diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/LogPrivacy.java b/teaql-runtime/src/main/java/io/teaql/runtime/LogPrivacy.java new file mode 100644 index 00000000..c1fe3751 --- /dev/null +++ b/teaql-runtime/src/main/java/io/teaql/runtime/LogPrivacy.java @@ -0,0 +1,94 @@ +package io.teaql.runtime; + +import io.teaql.core.ExecutionMetadata; +import io.teaql.core.TraceNode; +import java.util.*; +import java.util.concurrent.atomic.AtomicBoolean; + +/** Shared log projection boundary; execution and business event values are never mutated. */ +public final class LogPrivacy { + public static final String ENVIRONMENT = "TEAQL_ALLOW_SENSITIVE_PLAINTEXT_LOGS"; + public static final String ACKNOWLEDGEMENT = "I_UNDERSTAND_SENSITIVE_DATA_MAY_BE_WRITTEN_TO_DISK"; + public static final String REDACTED = "[REDACTED]"; + private static final AtomicBoolean WARNED = new AtomicBoolean(); + private LogPrivacy() {} + + public static boolean plaintextEnabled() { + if (!ACKNOWLEDGEMENT.equals(System.getenv(ENVIRONMENT))) return false; + if (WARNED.compareAndSet(false, true)) System.err.println( + "[TeaQL WARNING] Sensitive plaintext logging enabled; business data may be written to disk. Credentials remain redacted."); + return true; + } + + public static boolean credential(String name) { + String key = Objects.toString(name, "").replaceAll("[^a-zA-Z0-9]", "").toLowerCase(Locale.ROOT); + return List.of("password", "passwd", "passphrase", "privatekey", "secret", "accesstoken", + "refreshtoken", "idtoken", "apikey", "authorization", "credential", "sessiontoken", "magiclinktoken") + .stream().anyMatch(key::contains); + } + + public static boolean hasCredentials(Object value) { + if (value instanceof Map map) return map.entrySet().stream() + .anyMatch(e -> credential(String.valueOf(e.getKey())) || hasCredentials(e.getValue())); + if (value instanceof Iterable items) for (Object item : items) if (hasCredentials(item)) return true; + return false; + } + + private static void collect(Object value, List result) { + if (value == null) return; + if (value instanceof Map map) { map.values().forEach(item -> collect(item, result)); return; } + if (value instanceof Iterable items) { items.forEach(item -> collect(item, result)); return; } + String text = String.valueOf(value); + if (!text.isEmpty()) result.add(text); + } + + public static String scrub(String text, Collection values) { + if (text == null) return null; + List strings = new ArrayList<>(); + values.forEach(value -> collect(value, strings)); + strings.sort(Comparator.comparingInt(String::length).reversed()); + for (String value : strings) text = text.replace(value, REDACTED); + return text; + } + + public static List trace(List nodes, Collection values) { + if (nodes == null) return List.of(); + return nodes.stream().map(node -> new TraceNode(node.getKind(), + scrub(node.getName(), values), scrub(node.getComment(), values))).toList(); + } + + public static ExecutionMetadata sql(ExecutionMetadata source, boolean allow) { + boolean reveal = allow && !credential(source.getParameterizedQuery()) + && !credential(source.getDebugQuery()) && !hasCredentials(source.getParameters()); + List secrets = reveal ? List.of() : source.getParameters(); + ExecutionMetadata safe = new ExecutionMetadata(); + safe.setBackend(source.getBackend()); safe.setOperation(source.getOperation()); + safe.setStartedAt(source.getStartedAt()); safe.setEndedAt(source.getEndedAt()); + safe.setElapsedUs(source.getElapsedUs()); safe.setAffectedRows(source.getAffectedRows()); + safe.setResultCount(source.getResultCount()); safe.setResultSummary(scrub(source.getResultSummary(), secrets)); + safe.setBackendRequestId(scrub(source.getBackendRequestId(), secrets)); + safe.setComment(scrub(source.getComment(), secrets)); safe.setPurpose(scrub(source.getPurpose(), secrets)); + safe.setAuditReason(scrub(source.getAuditReason(), secrets)); safe.setTraceChain(trace(source.getTraceChain(), secrets)); + String sql = source.getParameterizedQuery(); + if (!reveal && sql != null && (sql.matches("(?s).*[0-9'\"`$].*") || sql.contains("--") || sql.contains("/*"))) + sql = "[REDACTED SQL; NOT REPLAYABLE]"; + safe.setParameterizedQuery(sql); + safe.setParameters(reveal ? source.getParameters() : Collections.nCopies(source.getParameterCount(), null)); + safe.setDebugQuery(reveal ? source.getDebugQuery() : null); + return safe; + } + + public static RawAuditEvent audit(RawAuditEvent source, boolean allow) { + List secrets = new ArrayList<>(); + List changes = source.changes().stream().map(change -> { + boolean mask = !allow || credential(change.field()) || hasCredentials(change.oldValue()) || hasCredentials(change.newValue()); + if (!mask) return change; + secrets.add(change.oldValue()); secrets.add(change.newValue()); + return new AuditFieldChange(change.field(), change.oldValue() == null ? null : REDACTED, + change.newValue() == null ? null : REDACTED); + }).toList(); + return new RawAuditEvent(source.kind(), source.entityType(), source.entityId(), changes, + trace(source.traceChain(), secrets), scrub(source.actor(), secrets), source.category(), + scrub(source.reason(), secrets), source.resultingVersion(), source.occurredAt()); + } +} diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/SensitiveDiagnosticTextRuntimeLogSink.java b/teaql-runtime/src/main/java/io/teaql/runtime/SensitiveDiagnosticTextRuntimeLogSink.java index 4e6ac6b5..c0c712c8 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/SensitiveDiagnosticTextRuntimeLogSink.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/SensitiveDiagnosticTextRuntimeLogSink.java @@ -22,11 +22,12 @@ public SensitiveDiagnosticTextRuntimeLogSink(PrintStream output) { @Override public boolean requiresSensitiveSqlData() { - return true; + return LogPrivacy.plaintextEnabled(); } @Override public void writeExecutionLog(UserContext context, ExecutionMetadata metadata) { + metadata = LogPrivacy.sql(metadata, LogPrivacy.plaintextEnabled()); output.printf( "[TeaQL SQL][%s][%dus] %s comment=%s purpose=%s auditReason=%s tracePath=%s%n" + "Parameterized SQL: %s params=%s%nDebug SQL: %s%n", diff --git a/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java index cad78fb2..96e3ab9d 100644 --- a/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java +++ b/teaql-runtime/src/main/java/io/teaql/runtime/TeaQLRuntime.java @@ -85,7 +85,7 @@ public boolean isExecutionLoggingEnabled() { public boolean isMutationExecutionLoggingEnabled() { return mutationExecutionLoggingEnabled; } public boolean requiresSensitiveSqlLogData() { - return logSink != null && logSink.requiresSensitiveSqlData(); + return logSink != null && logSink.requiresSensitiveSqlData() && LogPrivacy.plaintextEnabled(); } public RuntimeTelemetry getTelemetry() { @@ -127,7 +127,7 @@ GeneratedSchemaBootstrap getGeneratedSchemaBootstrap() { public void recordExecutionMetadata(UserContext context, ExecutionMetadata metadata) { if (logSink != null) { - logSink.writeExecutionLog(context, metadata); + logSink.writeExecutionLog(context, LogPrivacy.sql(metadata, requiresSensitiveSqlLogData())); } } @@ -868,7 +868,7 @@ private void emitAuditEvent( // The standard sink is server-owned by TeaQLRuntime and cannot be replaced by // dynamic input or an application capability registered on UserContext. if (logSink != null) { - logSink.writeAuditEvent(context, rawEvent); + logSink.writeAuditEvent(context, LogPrivacy.audit(rawEvent, LogPrivacy.plaintextEnabled())); } AppAuditEventSink appSink = context.capability(AppAuditEventSink.class); @@ -889,11 +889,22 @@ private SafeAuditEvent buildSafeAuditEvent(RawAuditEvent event) { : new HashSet<>(descriptor.getAuditMaskFields()); Integer maxLength = descriptor == null ? null : descriptor.getAuditValueMaxLength(); List fields = new ArrayList<>(); + List sensitiveValues = new ArrayList<>(); + boolean allowPlaintext = LogPrivacy.plaintextEnabled(); + for (AuditFieldChange change : event.changes()) { + if ((!allowPlaintext && maskFields.contains(change.field())) || LogPrivacy.credential(change.field()) + || LogPrivacy.hasCredentials(change.oldValue()) || LogPrivacy.hasCredentials(change.newValue())) { + sensitiveValues.add(change.oldValue()); + sensitiveValues.add(change.newValue()); + } + } for (AuditFieldChange change : event.changes()) { Object value = change.newValue() != null ? change.newValue() : change.oldValue(); String raw = value == null ? null : String.valueOf(value); - boolean masked = raw != null && maskFields.contains(change.field()); - String safe = masked ? maskAuditValue(raw) : raw; + boolean masked = raw != null && ((!allowPlaintext && maskFields.contains(change.field())) + || LogPrivacy.credential(change.field()) || LogPrivacy.hasCredentials(change.oldValue()) + || LogPrivacy.hasCredentials(change.newValue())); + String safe = masked ? LogPrivacy.REDACTED : LogPrivacy.scrub(raw, sensitiveValues); int rawLength = raw == null ? 0 : raw.length(); boolean truncated = safe != null && maxLength != null && safe.length() > maxLength; if (truncated) safe = limitAuditValue(safe, maxLength); @@ -903,7 +914,7 @@ private SafeAuditEvent buildSafeAuditEvent(RawAuditEvent event) { safe == null ? null : safe.length())); } return new SafeAuditEvent( - event.kind(), event.entityType(), event.entityId(), fields, event.traceChain()); + event.kind(), event.entityType(), event.entityId(), fields, LogPrivacy.trace(event.traceChain(), sensitiveValues)); } static String maskAuditValue(String value) { diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/LogPrivacyTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/LogPrivacyTest.java new file mode 100644 index 00000000..6890332b --- /dev/null +++ b/teaql-runtime/src/test/java/io/teaql/runtime/LogPrivacyTest.java @@ -0,0 +1,98 @@ +package io.teaql.runtime; + +import io.teaql.core.ExecutionMetadata; +import io.teaql.core.TraceNode; +import java.io.PrintStream; +import java.nio.file.Files; +import java.util.List; +import org.junit.Test; +import static org.junit.Assert.*; + +public class LogPrivacyTest { + @Test public void realProcessEnvironmentControlsFileOutput() throws Exception { + for (String setting : new String[] { "", "true", LogPrivacy.ACKNOWLEDGEMENT + " ", LogPrivacy.ACKNOWLEDGEMENT }) { + var output = Files.createTempFile("teaql-log-process-", ".log"); + var process = new ProcessBuilder( + java.nio.file.Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "-cp", childClasspath(), + LogPrivacyTest.class.getName()); + process.environment().remove(LogPrivacy.ENVIRONMENT); + if (!setting.isEmpty()) process.environment().put(LogPrivacy.ENVIRONMENT, setting); + process.redirectErrorStream(true).redirectOutput(output.toFile()); + var child = process.start(); + assertTrue("privacy child timed out", child.waitFor(30, java.util.concurrent.TimeUnit.SECONDS)); + String text = Files.readString(output); + assertEquals(text, 0, child.exitValue()); + boolean enabled = setting.equals(LogPrivacy.ACKNOWLEDGEMENT); + assertEquals(text, enabled, text.contains("PRIVATE-CUSTOMER-CANARY")); + assertEquals(text, enabled, text.contains("may be written to disk")); + assertFalse(text, text.contains("PASSWORD-CANARY")); + } + } + + public static void main(String[] args) { + var sink = new SensitiveDiagnosticTextRuntimeLogSink(System.out); + sink.writeExecutionLog(null, entry("name", "PRIVATE-CUSTOMER-CANARY")); + sink.writeExecutionLog(null, entry("password", "PASSWORD-CANARY")); + } + + private static String childClasspath() throws Exception { + var paths = new java.util.ArrayList(); + paths.add(System.getProperty("surefire.test.class.path", System.getProperty("java.class.path"))); + // Surefire places named runtime modules on a module path, not necessarily + // in java.class.path. Locate the actually loaded local classes explicitly. + for (Class type : List.of(LogPrivacyTest.class, LogPrivacy.class, ExecutionMetadata.class)) + paths.add(java.nio.file.Path.of(type.getProtectionDomain().getCodeSource().getLocation().toURI()).toString()); + return String.join(java.io.File.pathSeparator, paths); + } + + @Test public void nestedAuditCredentialsAndTraceStayMaskedUnderOptIn() { + var oldValue = java.util.Map.of("access_token", "OLD-TOKEN-CANARY"); + var newValue = java.util.Map.of("access_token", "NEW-TOKEN-CANARY"); + var source = new RawAuditEvent(MutationAuditKind.UPDATED, "Customer", 1L, + List.of(new AuditFieldChange("settings", oldValue, newValue)), + List.of(new TraceNode("OLD-TOKEN-CANARY became NEW-TOKEN-CANARY")), + "operator", "mutation", "replace NEW-TOKEN-CANARY", 2L, null); + var safe = LogPrivacy.audit(source, true); + assertEquals("[REDACTED]", safe.changes().get(0).newValue()); + assertFalse(safe.toString().contains("TOKEN-CANARY")); + assertEquals(newValue, source.changes().get(0).newValue()); + } + + @Test public void projectionIsIndependentAndCredentialsStayHidden() { + ExecutionMetadata source = entry("name", "PRIVATE-CUSTOMER-CANARY"); + ExecutionMetadata safe = LogPrivacy.sql(source, false); + assertNull(safe.getParameters().get(0)); + assertFalse(safe.getComment().contains("PRIVATE-CUSTOMER-CANARY")); + assertFalse(safe.getTraceChain().toString().contains("PRIVATE-CUSTOMER-CANARY")); + assertEquals("PRIVATE-CUSTOMER-CANARY", source.getParameters().get(0)); + assertEquals(source.getParameters(), LogPrivacy.sql(source, true).getParameters()); + ExecutionMetadata credential = LogPrivacy.sql(entry("password", "PASSWORD-CANARY"), true); + assertNull(credential.getParameters().get(0)); + assertNull(credential.getDebugQuery()); + } + + @Test public void directFileSinkCannotLeakCredentials() throws Exception { + var file = Files.createTempFile("teaql-privacy-", ".log"); + try (var output = new PrintStream(Files.newOutputStream(file))) { + new SensitiveDiagnosticTextRuntimeLogSink(output).writeExecutionLog(null, entry("password", "PASSWORD-CANARY")); + } + assertFalse(Files.readString(file).contains("PASSWORD-CANARY")); + } + + @Test public void literalSqlIsNotPresentedAsReplayable() { + ExecutionMetadata source = entry("name", "PRIVATE-CUSTOMER-CANARY"); + source.setParameterizedQuery(source.getDebugQuery()); + assertEquals("[REDACTED SQL; NOT REPLAYABLE]", LogPrivacy.sql(source, false).getParameterizedQuery()); + } + + private static ExecutionMetadata entry(String field, String value) { + var metadata = new ExecutionMetadata(); + metadata.setParameterizedQuery("select * from customer where " + field + " = ?"); + metadata.setDebugQuery("select * from customer where " + field + " = '" + value + "'"); + metadata.setParameters(List.of(value)); + metadata.setComment("load " + value); + metadata.setTraceChain(List.of(new TraceNode("load " + value))); + return metadata; + } +} diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/TeaQLRuntimeTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/TeaQLRuntimeTest.java index c90ece3b..f2d02269 100644 --- a/teaql-runtime/src/test/java/io/teaql/runtime/TeaQLRuntimeTest.java +++ b/teaql-runtime/src/test/java/io/teaql/runtime/TeaQLRuntimeTest.java @@ -195,7 +195,7 @@ public void executionMetadataRetainsStructuredIntentAndThreeLevelTrace() { Assert.assertEquals(TraceKind.PROVIDER, recorded.getTraceChain().get(5).getKind()); Assert.assertEquals(TraceKind.SQL, recorded.getTraceChain().get(6).getKind()); Assert.assertEquals("SELECT name FROM school_data WHERE id = ?", recorded.getParameterizedQuery()); - Assert.assertEquals("SELECT name FROM school_data WHERE id = 7", recorded.getDebugQuery()); + Assert.assertNull(recorded.getDebugQuery()); } public static class DummyMetaFactory implements EntityMetaFactory { @@ -825,7 +825,7 @@ public EntityDescriptor resolveEntityDescriptor(String type) { RawAuditEvent raw = standardSink.auditEvents.get(0); Assert.assertEquals(MutationAuditKind.CREATED, raw.kind()); Assert.assertEquals(Long.valueOf(700L), raw.entityId()); - Assert.assertEquals("private-value", raw.changes().stream() + Assert.assertEquals("[REDACTED]", raw.changes().stream() .filter(change -> "name".equals(change.field())) .findFirst().orElseThrow().newValue()); Assert.assertTrue(raw.traceChain().stream().anyMatch(node -> diff --git a/teaql-runtime/src/test/java/io/teaql/runtime/TextRuntimeLogSinkTest.java b/teaql-runtime/src/test/java/io/teaql/runtime/TextRuntimeLogSinkTest.java index 09621200..183452db 100644 --- a/teaql-runtime/src/test/java/io/teaql/runtime/TextRuntimeLogSinkTest.java +++ b/teaql-runtime/src/test/java/io/teaql/runtime/TextRuntimeLogSinkTest.java @@ -27,12 +27,10 @@ public void defaultSinkKeepsIntentAndParameterizedSqlButOmitsValues() { } @Test - public void sensitiveDiagnosticSinkExplicitlyEmitsValuesAndCopyPasteSql() { + public void sensitiveDiagnosticSinkWithoutAcknowledgementRemainsSafe() { String text = render(new SensitiveDiagnosticTextRuntimeLogSink(output())); - assertTrue(text.contains("params=[" + SECRET + "]")); - assertTrue(text.contains("Debug SQL: SELECT id FROM customer_data WHERE name = '" - + SECRET + "'")); + assertFalse(text.contains(SECRET)); } @Test @@ -56,7 +54,7 @@ public void diagnosticSelectionDoesNotDisableOperationLogging() { assertTrue(sensitive.getLogSink() instanceof SensitiveDiagnosticTextRuntimeLogSink); assertFalse(sensitive.isQueryExecutionLoggingEnabled()); assertTrue(sensitive.isMutationExecutionLoggingEnabled()); - assertTrue(sensitive.requiresSensitiveSqlLogData()); + assertFalse(sensitive.requiresSensitiveSqlLogData()); assertFalse(((RuntimeLogSink) (context, metadata) -> {}).requiresSensitiveSqlData()); assertFalse(new DefaultUserContext(null).requiresSensitiveSqlLogData()); assertFalse(new DefaultTextRuntimeLogSink(output()) { diff --git a/teaql-snowflake/pom.xml b/teaql-snowflake/pom.xml index e40c12b7..e1db44fc 100644 --- a/teaql-snowflake/pom.xml +++ b/teaql-snowflake/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE teaql-snowflake teaql-snowflake diff --git a/teaql-sql-portable/pom.xml b/teaql-sql-portable/pom.xml index ebc89d00..db83bbe4 100644 --- a/teaql-sql-portable/pom.xml +++ b/teaql-sql-portable/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE teaql-sql-portable diff --git a/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLRepository.java b/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLRepository.java index b6f8e1bf..88bced3e 100644 --- a/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLRepository.java +++ b/teaql-sql-portable/src/main/java/io/teaql/core/sql/portable/PortableSQLRepository.java @@ -2024,8 +2024,10 @@ protected boolean ensureTableEnabled(UserContext context) { return context.getBool("ensureTable", true); } - private void logInfo(String message) { - System.out.println("[SQL-PORTABLE] " + message); + static void logInfo(String message) { + // Legacy schema/bootstrap SQL may embed literal seed values. This layer + // has no field provenance and must not bypass the runtime log policy. + System.out.println("[SQL-PORTABLE] schema/bootstrap statement; SQL payload omitted"); } protected int toIntOrZero(Object cnt) { diff --git a/teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/PortableLogPrivacyTest.java b/teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/PortableLogPrivacyTest.java new file mode 100644 index 00000000..f32cab13 --- /dev/null +++ b/teaql-sql-portable/src/test/java/io/teaql/core/sql/portable/PortableLogPrivacyTest.java @@ -0,0 +1,23 @@ +package io.teaql.core.sql.portable; + +import static org.junit.Assert.*; +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import org.junit.Test; + +public class PortableLogPrivacyTest { + @Test public void legacyBootstrapLoggerNeverPrintsLiteralSeedValues() { + var original = System.out; + var bytes = new ByteArrayOutputStream(); + try (var output = new PrintStream(bytes)) { + System.setOut(output); + PortableSQLRepository.logInfo("INSERT INTO customer VALUES ('PRIVATE-SEED-CANARY')"); + } finally { + System.setOut(original); + } + String text = bytes.toString(java.nio.charset.StandardCharsets.UTF_8); + assertTrue(text.contains("SQL-PORTABLE")); + assertFalse(text.contains("PRIVATE-SEED-CANARY")); + assertFalse(text.contains("INSERT")); + } +} diff --git a/teaql-sqlite/pom.xml b/teaql-sqlite/pom.xml index d085cdd4..3790b2d2 100644 --- a/teaql-sqlite/pom.xml +++ b/teaql-sqlite/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE teaql-sqlite teaql-sqlite diff --git a/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteIntegrationTest.java b/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteIntegrationTest.java index d277011f..0a3c62b5 100644 --- a/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteIntegrationTest.java +++ b/teaql-sqlite/src/test/java/io/teaql/sqlite/SqliteIntegrationTest.java @@ -59,14 +59,59 @@ public boolean requiresSensitiveSqlData() { return true; } }); - assertTrue(diagnosticLogs.stream().anyMatch(log -> - log.getOperation() == DataServiceOperation.QUERY - && !log.getParameters().isEmpty() - && log.getDebugQuery() != null)); - assertTrue(diagnosticLogs.stream().anyMatch(log -> - log.getOperation() == DataServiceOperation.MUTATION - && !log.getParameters().isEmpty() - && log.getDebugQuery() != null)); + // Requesting sensitive data is not authorization: the exact environment + // acknowledgement is also required (covered by LogPrivacyTest). + assertFalse(diagnosticLogs.isEmpty()); + assertTrue(diagnosticLogs.stream().allMatch(log -> log.getParameters().isEmpty())); + assertTrue(diagnosticLogs.stream().allMatch(log -> log.getDebugQuery() == null)); + } + + @Test + public void realCrudPreservesValuesWithoutLoggingThem() throws Exception { + var path = java.nio.file.Files.createTempFile("teaql-privacy-crud-", ".log"); + List captured = new ArrayList<>(); + try (var output = new java.io.PrintStream(path.toFile())) { + var text = new io.teaql.runtime.DefaultTextRuntimeLogSink(output); + RuntimeLogSink sink = new RuntimeLogSink() { + @Override public boolean requiresSensitiveSqlData() { return true; } + @Override public void writeExecutionLog(UserContext caller, ExecutionMetadata metadata) { + captured.add(metadata); + text.writeExecutionLog(caller, metadata); + } + }; + var local = new DefaultUserContext(TeaQLRuntime.builder() + .metadata(runtime.getMetadata()) + .dataService("sqlite", runtime.getRegistry().resolve("sqlite")) + .idGenerationService(runtime.getIdGenerationService()).logSink(sink).build()); + Task task = new Task(); + task.updateTitle("PRIVATE-CREATE-CANARY"); + task.updateStatus("PRIVACY-FIXTURE"); + task.auditAs("create privacy fixture").save(local); + Task stale = new TaskRequest().filterByTitle("PRIVATE-CREATE-CANARY") + .comment("read fixture").purpose("verify original values").executeForList(local).get(0); + assertEquals("PRIVATE-CREATE-CANARY", stale.getTitle()); + task.updateTitle("PRIVATE-UPDATE-CANARY"); + task.auditAs("update privacy fixture").save(local); + stale.updateTitle("PRIVATE-FAILURE-CANARY"); + assertThrows(RuntimeException.class, () -> stale.auditAs("reject stale update").save(local)); + var rows = new TaskRequest().filterByTitle("PRIVATE-UPDATE-CANARY") + .comment("read updated fixture").purpose("verify failed write did not change data").executeForList(local); + assertEquals(1, rows.size()); + assertEquals("PRIVATE-UPDATE-CANARY", rows.get(0).getTitle()); + task.markForDeletion().auditAs("delete privacy fixture").save(local); + assertTrue(new TaskRequest().filterByTitle("PRIVATE-UPDATE-CANARY") + .comment("read deleted fixture").purpose("verify deletion").executeForList(local).isEmpty()); + } + assertFalse(captured.isEmpty()); + String logs = java.nio.file.Files.readString(path); + assertFalse(logs.isEmpty()); + for (var entry : captured) { + logs += entry.getParameterizedQuery() + " " + entry.getParameters() + " " + + entry.getDebugQuery() + " " + entry.getTraceChain() + " " + + entry.getComment() + " " + entry.getPurpose() + " " + entry.getAuditReason(); + } + for (String marker : List.of("PRIVATE-CREATE-CANARY", "PRIVATE-UPDATE-CANARY", "PRIVATE-FAILURE-CANARY")) + assertFalse("sensitive marker reached log destination", logs.contains(marker)); } private void executeLoggedQueryAndMutation(RuntimeLogSink sink) { diff --git a/teaql-tfp-endpoint/pom.xml b/teaql-tfp-endpoint/pom.xml index c0c6d8d5..a1746a63 100644 --- a/teaql-tfp-endpoint/pom.xml +++ b/teaql-tfp-endpoint/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE ../pom.xml diff --git a/teaql-tool-http/pom.xml b/teaql-tool-http/pom.xml index cc210171..c7716720 100644 --- a/teaql-tool-http/pom.xml +++ b/teaql-tool-http/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE teaql-tool-http diff --git a/teaql-utils-json/pom.xml b/teaql-utils-json/pom.xml index 13fc0d94..674dbd57 100644 --- a/teaql-utils-json/pom.xml +++ b/teaql-utils-json/pom.xml @@ -6,7 +6,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE teaql-utils-json diff --git a/teaql-utils-reflection/pom.xml b/teaql-utils-reflection/pom.xml index 23604c78..9d0c1778 100644 --- a/teaql-utils-reflection/pom.xml +++ b/teaql-utils-reflection/pom.xml @@ -7,7 +7,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE ../pom.xml diff --git a/teaql-utils-spring/pom.xml b/teaql-utils-spring/pom.xml index d7e7ea31..8ab536cd 100644 --- a/teaql-utils-spring/pom.xml +++ b/teaql-utils-spring/pom.xml @@ -7,7 +7,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE ../pom.xml diff --git a/teaql-utils/pom.xml b/teaql-utils/pom.xml index b7ec8150..5eaeb87f 100644 --- a/teaql-utils/pom.xml +++ b/teaql-utils/pom.xml @@ -7,7 +7,7 @@ io.teaql teaql-java-parent - 1.550-RELEASE + 1.551-RELEASE ../pom.xml