{ "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 VARIABLES =\n Map.of(\"code\", CODE, \"recipient\", RECIPIENT);\n\n private static final ThymeleafStringTemplateEngine ENGINE = new ThymeleafStringTemplateEngine();\n\n private static ListAppender 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 은 이 프로브가 만든 합성 값이다" }