The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.
Follows the import procedure in README.md.
source/ the originating repository verbatim — 78 documents, 28 SVGs,
8 manifests, plus .source-revision recording the commit
final/ the SSOT
document.md 729 lines written from the 29 experiment documents, not
concatenated: what was predicted, what was measured, and
where the measurement itself was wrong
evidence/raw 125 outputs, flattened to <experiment>__<file> because
the originals collided (01-baseline.txt appeared three
times) and the audit only globs the top level
evidence/meta one per raw file; command and exitCode are null and the
README says why rather than inventing them
evidence/browser 22 captures
assets/ three diagrams through techviz
.techviz/ their VizSpecs
A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.
Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.
verify-pipeline.py passes. audit-records.py reports no issues.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
15 lines
9.1 KiB
JSON
15 lines
9.1 KiB
JSON
{
|
|
"assetKey": "a13-f004-thymeleaf-messages",
|
|
"kind": "terminal",
|
|
"command": "set -e\nset -o pipefail\nD=$(mktemp -d); trap 'rm -rf \"$D\"' EXIT\nC=/shared/codebase/clean-architecture-backend-template\nN=$C/src/adapter/outbound/notification\nCACHE=/root/.gradle/caches/modules-2/files-2.1\njava -version 2>&1 | head -1\ncp=\"\"\nwhile IFS= read -r line; do\n coord=${line%%=*}; confs=${line#*=}\n case \",$confs,\" in *\",runtimeClasspath,\"*) ;; *) continue ;; esac\n g=${coord%%:*}; rest=${coord#*:}; n=${rest%%:*}; v=${rest##*:}\n jar=$(find \"$CACHE/$g/$n/$v\" -name '*.jar' ! -name '*sources*' ! -name '*javadoc*' 2>/dev/null | head -1)\n [ -n \"$jar\" ] && cp=\"$cp:$jar\"\ndone < <(grep -E '^[a-zA-Z0-9._-]+:[^=]+=' \"$N/gradle.lockfile\")\n# jackson-core 는 이 프로브가 부르지 않는 valueMasker() 를 컴파일하려고만 붙인다.\n# 여기서 재는 mask(String) 은 java.util.regex 만 쓴다.\nJACKSON=$(find \"$CACHE/com.fasterxml.jackson.core/jackson-core\" -name 'jackson-core-*.jar' ! -name '*sources*' | head -1)\nLOGSTASH=$(find \"$CACHE/net.logstash.logback/logstash-logback-encoder/8.0\" -name '*.jar' ! -name '*sources*' | head -1)\nCP=$(find \"$N/build/libs\" \"$C/src/application-core/build/libs\" -name '*+21234e38cdb9.jar' | tr '\\n' ':')$JACKSON:$LOGSTASH${cp}\n# 마스킹 카탈로그는 빌드 산출물이 아니라 고정 리비전 소스에서 바로 컴파일한다.\njavac -encoding UTF-8 -cp \"$CP\" -d \"$D\" \\\n \"$C/src/app-bootstrap/src/main/java/dev/caskeleton/bootstrap/logging/LogMaskingPatterns.java\"\ncat > \"$D/RenderFailureProbe.java\" <<'JAVA'\nimport ch.qos.logback.classic.LoggerContext;\nimport ch.qos.logback.classic.spi.ILoggingEvent;\nimport ch.qos.logback.classic.spi.ThrowableProxyUtil;\nimport ch.qos.logback.core.read.ListAppender;\nimport dev.caskeleton.adapter.outbound.notification.platform.template.TemplateSlotMode;\nimport dev.caskeleton.adapter.outbound.notification.platform.template.ThymeleafStringTemplateEngine;\nimport dev.caskeleton.bootstrap.logging.LogMaskingPatterns;\nimport java.util.HashMap;\nimport java.util.List;\nimport java.util.Map;\nimport org.slf4j.LoggerFactory;\n\n/** 같은 식을 두 오버로드에 넣고, 호출자가 받는 것과 로그에 남는 것을 따로 잰다. */\npublic final class RenderFailureProbe {\n\n private static final String CODE = \"483920\";\n\n /** 값 하나가 아니라 값 객체가 변수로 들어오는 경우를 보려고 둔 합성 레코드. */\n record Recipient(String email, String code) {}\n\n private static final Recipient RECIPIENT = new Recipient(\"alice@example.com\", CODE);\n private static final Map<String, Object> VARIABLES =\n Map.of(\"code\", CODE, \"recipient\", RECIPIENT);\n\n private static final ThymeleafStringTemplateEngine ENGINE = new ThymeleafStringTemplateEngine();\n\n private static ListAppender<ILoggingEvent> sink;\n\n /** 한 번 렌더한 결과: 호출자가 값을 보는가, 로그에 남는가, 마스킹이 지우는가. */\n private record Seen(boolean caller, boolean logged, boolean masked) {}\n\n /** 원인 사슬 전체를 잇는다. 최상위 메시지만 보면 아래에 담긴 값을 놓친다. */\n private static String chain(Throwable failure) {\n StringBuilder joined = new StringBuilder();\n for (Throwable step = failure; step != null; step = step.getCause()) {\n joined.append(step.getClass().getSimpleName()).append(\": \").append(step.getMessage());\n }\n return joined.toString();\n }\n\n /** logback 이 실제로 찍을 형태: 메시지 + 예외 사슬. */\n private static String drainLog() {\n StringBuilder printed = new StringBuilder();\n for (ILoggingEvent event : List.copyOf(sink.list)) {\n printed.append(event.getFormattedMessage());\n if (event.getThrowableProxy() != null) {\n printed.append(ThrowableProxyUtil.asString(event.getThrowableProxy()));\n }\n }\n sink.list.clear();\n return printed.toString();\n }\n\n private static boolean carries(String text) {\n return text.contains(CODE) || text.contains(\"alice@example.com\");\n }\n\n private static Seen measure(Runnable call) {\n sink.list.clear();\n String caught = \"\";\n try {\n call.run();\n } catch (RuntimeException failure) {\n caught = chain(failure);\n }\n String logged = drainLog();\n return new Seen(carries(caught), carries(logged), carries(LogMaskingPatterns.mask(logged)));\n }\n\n private static String mark(boolean seen) {\n return seen ? \"있음\" : \"없음\";\n }\n\n /** 두 오버로드를 나란히 재고 한 줄로 적는다. 로그 쪽이 갈리면 그렇게 적는다. */\n private static void shape(String source) {\n Seen plain = measure(() -> ENGINE.render(source, VARIABLES));\n Seen moded = measure(() -> ENGINE.render(TemplateSlotMode.TEXT, source, VARIABLES));\n String logged = plain.logged() == moded.logged() ? mark(moded.logged()) : \"두 쪽이 다름\";\n String masked = plain.masked() == moded.masked() ? mark(moded.masked()) : \"두 쪽이 다름\";\n System.out.println(source);\n System.out.printf(\" 호출자 모드없음 %s · 모드있음* %s · 로그 %s · 마스킹 뒤 %s%n\",\n mark(plain.caller()), mark(moded.caller()), logged, masked);\n }\n\n /** OGNL 이나 JDK 가 만든 첫 절. Thymeleaf 의 포장 절은 건너뛴다. */\n private static void clause(String label, Runnable call) {\n try {\n call.run();\n System.out.printf(\" %-30s (실패하지 않음)%n\", label);\n } catch (RuntimeException failure) {\n for (Throwable step = failure; step != null; step = step.getCause()) {\n String type = step.getClass().getName();\n if (!type.startsWith(\"ognl.\") && !type.startsWith(\"java.\")) {\n continue;\n }\n // toString 을 재정의하지 않은 객체는 실행마다 다른 주소를 남긴다. 여기서 재는 것은\n // 값이 적히는지 여부이므로 주소만 고정 표시로 바꾼다.\n String message = String.valueOf(step.getMessage()).replaceAll(\"@[0-9a-f]{4,}\", \"@<주소>\");\n System.out.printf(\" %-30s %s: %s%n\", label, step.getClass().getSimpleName(), message);\n return;\n }\n }\n }\n\n private static void clauseOf(String source) {\n clause(source, () -> ENGINE.render(TemplateSlotMode.TEXT, source, VARIABLES));\n }\n\n public static void main(String[] args) {\n LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory();\n // 기본 콘솔 어펜더를 뗀다. 남기면 재려는 로그가 잰 결과와 섞인다.\n context.getLogger(\"ROOT\").detachAndStopAllAppenders();\n sink = new ListAppender<>();\n sink.setContext(context);\n sink.start();\n context.getLogger(\"ROOT\").addAppender(sink);\n\n System.out.println();\n System.out.println(\"code=\" + CODE + \", recipient=\" + RECIPIENT + \" 를 넣고 렌더한다.\");\n System.out.println(\"* 표시가 프로덕션이 부르는 오버로드다.\");\n System.out.println();\n\n String[] leaking = {\n \"[[${code.substring(99)}]]\",\n \"[[${code.noSuchMethod()}]]\",\n \"[[${recipient.noSuchMethod()}]]\",\n \"[[${recipient.email.substring(99)}]]\",\n \"[[${#numbers.formatDecimal(code, 1, 2)}]]\",\n \"[[${code[recipient]}]]\",\n \"[[${recipient * 2}]]\",\n };\n String[] quiet = {\n \"[[${#strings.substring(code, 99, 200)}]]\",\n \"[[${code.noSuchProperty}]]\",\n \"[[${code.length() / 0}]]\",\n };\n\n System.out.println(\"[실패한 자리에 변수가 놓인 식]\");\n for (String source : leaking) {\n shape(source);\n }\n System.out.println();\n\n System.out.println(\"[실패한 자리에 변수가 없는 식]\");\n for (String source : quiet) {\n shape(source);\n }\n System.out.println(\"[[${code}]] (이 줄만 변수를 하나도 주지 않는다)\");\n Seen plain = measure(() -> ENGINE.render(\"[[${code}]]\", new HashMap<>()));\n Seen moded =\n measure(() -> ENGINE.render(TemplateSlotMode.TEXT, \"[[${code}]]\", new HashMap<>()));\n System.out.printf(\" 호출자 모드없음 %s · 모드있음* %s · 로그 %s · 마스킹 뒤 %s%n\",\n mark(plain.caller()), mark(moded.caller()), mark(moded.logged()), mark(moded.masked()));\n System.out.println();\n\n System.out.println(\"[OGNL 이나 JDK 가 만든 절]\");\n for (String source : leaking) {\n clauseOf(source);\n }\n for (String source : quiet) {\n clauseOf(source);\n }\n }\n}\nJAVA\njavac -encoding UTF-8 -cp \"$CP:$D\" -d \"$D\" \"$D/RenderFailureProbe.java\"\njava -Dstdout.encoding=UTF-8 -cp \"$CP:$D\" RenderFailureProbe\n",
|
|
"cwd": "/shared/codebase/clean-architecture-backend-template/src",
|
|
"exitCode": 0,
|
|
"executedAt": "2026-09-02T16:07:05+00:00",
|
|
"sourceRevision": "21234e38cdb9a926cbc92bb97a2aee2e4a7d2916",
|
|
"raw": "evidence/raw/a13-f004-thymeleaf-messages.txt",
|
|
"svg": "evidence/rendered/a13-f004-thymeleaf-messages.svg",
|
|
"rawSha256": "d8c0d6bc4cb2f233269dd6b148c85aa64f97fcb5af398debb0f2c362722cc262",
|
|
"lines": 42,
|
|
"redaction": "none — 483920 과 alice@example.com 은 이 프로브가 만든 합성 값이다"
|
|
}
|