Skip to content

Commit 9474430

Browse files
DreierFclaude
andcommitted
TS-46179 Obfuscate access token in DEBUG and WARN logs
The Teamscale access token leaked in clear text into three log sites that are forwarded to Teamscale when the LogToTeamscaleAppender is active (config-id + debug=true): the parser-level "Parsing options" and "Received options from Teamscale" debug logs, plus the "multiple java agents" warn log. Existing INFO-level obfuscation already covered the startup banner. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9cc1163 commit 9474430

8 files changed

Lines changed: 131 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ We use [semantic versioning](http://semver.org/):
55
- PATCH version when you make backwards compatible bug fixes.
66

77
# Next version
8+
- [security fix] _agent_: The Teamscale access token was logged in clear text in DEBUG-level logs (e.g., when `debug=true` was set) and in the WARN-level log emitted when multiple `-javaagent` arguments are present. The token is now obfuscated in those logs as well, matching INFO-level behavior.
89

910
# 36.5.1
1011
- [fix] _agent_: The profiled application no longer crashes when the profiler configuration is invalid (e.g., missing `teamscale-user`). Instead, the application starts normally without coverage collection.

agent/src/main/java/com/teamscale/jacoco/agent/PreMain.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ private static Pair<AgentOptions, List<Exception>> getAndApplyAgentOptions(Strin
184184
if (!differentAgents.isEmpty()) {
185185
delayedLogger.warn(
186186
"Using multiple java agents could interfere with coverage recording: " +
187-
String.join(", ", differentAgents));
187+
AgentOptions.obfuscateAccessToken(String.join(", ", differentAgents)));
188188
}
189189
if (!javaAgents.get(0).contains("teamscale-jacoco-agent.jar")) {
190190
delayedLogger.warn("For best results consider registering the Teamscale Java Profiler first.");

agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptions.java

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -256,20 +256,35 @@ public String getOriginalOptionsString() {
256256
* "config-file=jacocoagent.properties,teamscale-access-token=************mNHn"
257257
*/
258258
public String getObfuscatedOptionsString() {
259-
if (getOriginalOptionsString() == null) {
259+
return obfuscateAccessToken(getOriginalOptionsString());
260+
}
261+
262+
/**
263+
* Obfuscates any "*-access-token=..." value in the given options string for safe logging. Keeps only the last 4
264+
* characters of each token. Returns an empty string for {@code null} input and the original string when no token
265+
* pattern matches.
266+
*/
267+
public static String obfuscateAccessToken(String optionsString) {
268+
if (optionsString == null) {
260269
return "";
261270
}
262271

263-
Pattern pattern = Pattern.compile("(.*-access-token=)([^,]+)(.*)");
264-
Matcher match = pattern.matcher(getOriginalOptionsString());
265-
if (match.find()) {
272+
Pattern pattern = Pattern.compile("(-access-token=)([^,\\n\\r]+)");
273+
Matcher match = pattern.matcher(optionsString);
274+
StringBuffer obfuscated = new StringBuffer();
275+
boolean foundAny = false;
276+
while (match.find()) {
277+
foundAny = true;
266278
String apiKey = match.group(2);
267279
String obfuscatedApiKey = String.format("************%s", apiKey.substring(Math.max(0,
268280
apiKey.length() - 4)));
269-
return String.format("%s%s%s", match.group(1), obfuscatedApiKey, match.group(3));
281+
match.appendReplacement(obfuscated, Matcher.quoteReplacement(match.group(1) + obfuscatedApiKey));
270282
}
271-
272-
return getOriginalOptionsString();
283+
if (!foundAny) {
284+
return optionsString;
285+
}
286+
match.appendTail(obfuscated);
287+
return obfuscated.toString();
273288
}
274289

275290
/**

agent/src/main/java/com/teamscale/jacoco/agent/options/AgentOptionsParser.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ public void throwOnCollectedErrors() throws Exception {
122122
if (optionsString == null) {
123123
optionsString = "";
124124
}
125-
logger.debug("Parsing options: " + optionsString);
125+
logger.debug("Parsing options: " + AgentOptions.obfuscateAccessToken(optionsString));
126126

127127
AgentOptions options = new AgentOptions(logger);
128128
options.originalOptionsString = optionsString;
@@ -432,7 +432,8 @@ private void readConfigFromTeamscale(
432432
options.teamscaleServer.userAccessToken);
433433
options.configurationViaTeamscale = configuration;
434434
logger.debug(
435-
"Received the following options from Teamscale: " + configuration.getProfilerConfiguration().configurationOptions);
435+
"Received the following options from Teamscale: " + AgentOptions.obfuscateAccessToken(
436+
configuration.getProfilerConfiguration().configurationOptions));
436437
readConfigFromString(options, configuration.getProfilerConfiguration().configurationOptions);
437438
}
438439

agent/src/test/java/com/teamscale/jacoco/agent/options/AgentOptionsTest.java

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,50 @@ public void configIdWithAllMissingOptionsReportsAll() {
510510
.hasMessageContaining("teamscale-access-token");
511511
}
512512

513+
/** Tests that {@link AgentOptions#obfuscateAccessToken(String)} hides the token in a comma-separated options string. */
514+
@Test
515+
public void obfuscateAccessTokenHidesTokenInOptionsString() {
516+
String input = "config-file=jacocoagent.properties,teamscale-access-token=unlYgehaYYYhbPAegNWV3WgjOzxkmNHn,teamscale-partition=p";
517+
assertThat(AgentOptions.obfuscateAccessToken(input)).isEqualTo(
518+
"config-file=jacocoagent.properties,teamscale-access-token=************mNHn,teamscale-partition=p");
519+
}
520+
521+
/** Tests that obfuscation also covers tokens in newline-separated input (the format Teamscale returns for config-id). */
522+
@Test
523+
public void obfuscateAccessTokenHidesTokenInNewlineSeparatedString() {
524+
String input = "teamscale-access-token=unlYgehaYYYhbPAegNWV3WgjOzxkmNHn\nteamscale-partition=p";
525+
assertThat(AgentOptions.obfuscateAccessToken(input)).isEqualTo(
526+
"teamscale-access-token=************mNHn\nteamscale-partition=p");
527+
}
528+
529+
/** Tests that obfuscation hides every {@code *-access-token=} occurrence, not just the last one. */
530+
@Test
531+
public void obfuscateAccessTokenHidesMultipleTokens() {
532+
String input = "teamscale-access-token=unlYgehaYYYhbPAegNWV3WgjOzxkmNHn,artifactory-access-token=anotherSecretAbcd";
533+
assertThat(AgentOptions.obfuscateAccessToken(input)).isEqualTo(
534+
"teamscale-access-token=************mNHn,artifactory-access-token=************Abcd");
535+
}
536+
537+
/** Tests that strings without an access token are returned unchanged. */
538+
@Test
539+
public void obfuscateAccessTokenReturnsInputUnchangedWhenNoTokenPresent() {
540+
String input = "config-file=jacocoagent.properties,teamscale-partition=p";
541+
assertThat(AgentOptions.obfuscateAccessToken(input)).isEqualTo(input);
542+
}
543+
544+
/** Tests the null-input contract used by {@link AgentOptions#getObfuscatedOptionsString()}. */
545+
@Test
546+
public void obfuscateAccessTokenReturnsEmptyStringForNullInput() {
547+
assertThat(AgentOptions.obfuscateAccessToken(null)).isEmpty();
548+
}
549+
550+
/** Tests that a token shorter than 4 characters does not throw and is fully obfuscated. */
551+
@Test
552+
public void obfuscateAccessTokenHandlesShortToken() {
553+
assertThat(AgentOptions.obfuscateAccessToken("teamscale-access-token=abc")).isEqualTo(
554+
"teamscale-access-token=************abc");
555+
}
556+
513557
/**
514558
* Delete created coverage folders
515559
*/

common-system-test/src/main/kotlin/com/teamscale/test/commons/TeamscaleMockServer.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ class TeamscaleMockServer(val port: Int) {
4343
private val service = Service.ignite()
4444
private var impactedTests = listOf<String>()
4545
val profilerEvents = mutableListOf<String>()
46+
47+
/** All log entries that the profiler forwarded to this server via {@code POST /profilers/:profilerId/logs}. */
48+
val collectedLogMessages = mutableListOf<ProfilerLogEntry>()
4649
private var profilerConfiguration: ProfilerConfiguration? = null
4750
private var username: String? = null
4851
private var accessToken: String? = null
@@ -70,6 +73,7 @@ class TeamscaleMockServer(val port: Int) {
7073
sessions.clear()
7174
allAvailableTests.clear()
7275
profilerEvents.clear()
76+
collectedLogMessages.clear()
7377
impactedTestCommits.clear()
7478
baselines.clear()
7579
}
@@ -229,6 +233,11 @@ class TeamscaleMockServer(val port: Int) {
229233

230234
request.collectUserAgent()
231235
profilerEvents.add("Profiler " + request.params(":profilerId") + " sent logs")
236+
try {
237+
collectedLogMessages.addAll(deserializeList<ProfilerLogEntry>(request.body()))
238+
} catch (e: JsonProcessingException) {
239+
// Body wasn't a list of ProfilerLogEntry — ignore so we don't break tests that don't care about log bodies.
240+
}
232241
return ""
233242
}
234243

system-tests/teamscale-profiler-configuration-test/src/test/kotlin/com/teamscale/client/TeamscaleProfilerConfigurationSystemTest.kt

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,50 @@ class TeamscaleProfilerConfigurationSystemTest {
5656
assertThat(result.stdout).contains("Production code")
5757
}
5858

59+
/**
60+
* Reproduces the customer-reported scenario (dmTech) where enabling debug logging caused the
61+
* `teamscale-access-token` to appear in clear text in the debug logs that are forwarded back to Teamscale via
62+
* [com.teamscale.jacoco.agent.logging.LogToTeamscaleAppender]. Asserts that the raw token never reaches the
63+
* server and that the obfuscated form does, proving the obfuscation is wired up at the parser-level debug logs.
64+
*/
65+
@Test
66+
@Throws(Exception::class)
67+
fun systemTestDebugLogsObfuscateAccessToken() {
68+
val profilerConfiguration = ProfilerConfiguration().apply {
69+
configurationId = "my-config"
70+
configurationOptions = "teamscale-partition=foo\nteamscale-project=p\nteamscale-commit=master:12345"
71+
}
72+
val teamscaleMockServer = TeamscaleMockServer(FAKE_TEAMSCALE_PORT).acceptingReportUploads()
73+
.withProfilerConfiguration(profilerConfiguration)
74+
75+
val agentJar = System.getenv("AGENT_JAR")
76+
val sampleJar = System.getenv("SYSTEM_UNDER_TEST_JAR")
77+
val agentOptions = "config-id=my-config,teamscale-server-url=http://localhost:$FAKE_TEAMSCALE_PORT," +
78+
"teamscale-user=fake,teamscale-access-token=$SECRET_ACCESS_TOKEN,debug=true"
79+
val result = ProcessUtils.execute("java", "-javaagent:$agentJar=$agentOptions", "-jar", sampleJar)
80+
println(result.stderr)
81+
println(result.stdout)
82+
assertThat(result.exitCode).isEqualTo(0)
83+
84+
teamscaleMockServer.shutdown()
85+
86+
val forwardedLogs = teamscaleMockServer.collectedLogMessages.joinToString("\n") { "${it.severity} ${it.message}" }
87+
assertThat(forwardedLogs)
88+
.`as`("Sanity check: the parser-level debug log must be forwarded for this test to be meaningful")
89+
.contains("Parsing options:")
90+
assertThat(forwardedLogs)
91+
.`as`("Forwarded debug logs must not contain the raw access token in clear text")
92+
.doesNotContain(SECRET_ACCESS_TOKEN)
93+
assertThat(forwardedLogs)
94+
.`as`("Forwarded debug logs must show the access token in obfuscated form")
95+
.contains("************" + SECRET_ACCESS_TOKEN.takeLast(4))
96+
}
97+
5998
companion object {
6099
/** These ports must match what is configured for the -javaagent line in this project's build.gradle. */
61100
private const val FAKE_TEAMSCALE_PORT = 64100
101+
102+
/** A clearly-marked secret used only by the obfuscation test so any leak in the assertion output is obvious. */
103+
private const val SECRET_ACCESS_TOKEN = "topSecretToken12345"
62104
}
63105
}
Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,19 @@
11
package com.teamscale.client
22

3+
import com.fasterxml.jackson.annotation.JsonCreator
4+
import com.fasterxml.jackson.annotation.JsonProperty
5+
36
/** A log entry to be sent to Teamscale */
4-
class ProfilerLogEntry(
7+
class ProfilerLogEntry @JsonCreator constructor(
58
/** The time of the event */
6-
var timestamp: Long,
9+
@param:JsonProperty("timestamp") var timestamp: Long,
710

811
/** Log message */
9-
var message: String,
12+
@param:JsonProperty("message") var message: String,
1013

1114
/** Details, for example, the stack trace */
12-
var details: String?,
15+
@param:JsonProperty("details") var details: String?,
1316

1417
/** Event severity */
15-
var severity: String
16-
)
18+
@param:JsonProperty("severity") var severity: String
19+
)

0 commit comments

Comments
 (0)