Skip to content

Commit d56f988

Browse files
authored
Merge pull request #890 from cqse/ts/46179_access_key_log
TS-46179 Obfuscate access token in DEBUG and WARN logs
2 parents 9cc1163 + 9474430 commit d56f988

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)