Skip to content

Commit e05a722

Browse files
committed
Stop logging secrets with a detach command
- Add 'detach <pid>' subcommand that stops secrets logging without restarting the target process. Allows for a safe attach/detach/re-attach cycles. - Add 'attach <pid> [<secrets_file>]' as an explicit subcommand, consistent with detach a nd list. Retain bare '<pid> [<secrets_file>]' for backwards compatibility without documenting it. - Add integration tests covering the full attach/detach/re-attach lifecycle and CLI argum ent-error cases for the new subcommands. Fixes #26
1 parent 39f903d commit e05a722

7 files changed

Lines changed: 272 additions & 24 deletions

File tree

src/main/java/name/neykov/secrets/agent/AgentMain.java

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
public class AgentMain {
2626
private static final Logger log = Logger.getLogger(AgentMain.class.getName());
2727

28+
private static volatile Transformer activeTransformer = null;
29+
private static volatile Instrumentation attachInstr = null;
30+
2831
// Created in process working directory
2932
public static final String DEFAULT_SECRETS_FILE = "ssl-master-secrets.txt";
3033

@@ -39,7 +42,11 @@ public static void premain(String agentArgs, Instrumentation inst) {
3942
public static void agentmain(String agentArgs, Instrumentation inst) {
4043
File jarFile = getJarFile();
4144
initClassPath(inst, jarFile);
42-
attach(agentArgs, inst, jarFile);
45+
if ("detach".equals(agentArgs)) {
46+
detach(jarFile);
47+
} else {
48+
attach(agentArgs, inst, jarFile);
49+
}
4350
reloadClasses(inst);
4451
}
4552

@@ -144,14 +151,21 @@ private static void reloadClasses(Instrumentation inst) {
144151
}
145152
}
146153

147-
private static void attach(String agentArgs, Instrumentation inst, File jarFile) {
154+
private static void attach(String secretsPath, Instrumentation inst, File jarFile) {
155+
if (activeTransformer != null) {
156+
log.warning("Already attached; ignoring attach request.");
157+
return;
158+
}
159+
148160
openBaseModule(inst);
149161

150162
// MasterSecretCallback is loaded in boot class loader
151-
String canonicalSecretsPath = getCanonicalSecretsPath(agentArgs);
163+
String canonicalSecretsPath = getCanonicalSecretsPath(secretsPath);
152164
MasterSecretCallback.setSecretsFileName(canonicalSecretsPath);
153165

154-
inst.addTransformer(new Transformer(), true);
166+
activeTransformer = new Transformer();
167+
attachInstr = inst;
168+
inst.addTransformer(activeTransformer, true);
155169

156170
logSecurityProviders();
157171

@@ -163,6 +177,19 @@ private static void attach(String agentArgs, Instrumentation inst, File jarFile)
163177
+ ". ");
164178
}
165179

180+
private static void detach(File jarFile) {
181+
if (activeTransformer == null) {
182+
log.warning("Not attached; ignoring detach request.");
183+
return;
184+
}
185+
186+
attachInstr.removeTransformer(activeTransformer);
187+
activeTransformer = null;
188+
attachInstr = null;
189+
190+
log.info("Successfully detached agent " + jarFile + ". ");
191+
}
192+
166193
private static void logSecurityProviders() {
167194
Provider[] sslProviders = Security.getProviders("SSLContext.TLS");
168195
if (sslProviders == null || sslProviders.length == 0) {

src/main/java/name/neykov/secrets/cli/AgentAttach.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ public static void main(String[] args) throws Exception {
2020

2121
try {
2222
CliArguments cliArguments = CliArguments.parse(args);
23-
handle(jarUrl, jarFile, cliArguments.listOrPid, cliArguments.secretsPath);
23+
String listOrPid = "list".equals(cliArguments.action) ? "list" : cliArguments.pid;
24+
String attachOptions =
25+
"detach".equals(cliArguments.action) ? "detach" : cliArguments.secretsPath;
26+
handle(jarUrl, jarFile, listOrPid, attachOptions);
2427
} catch (IllegalArgumentException e) {
2528
help(jarFile, e.getMessage());
2629
System.exit(1);
@@ -35,10 +38,14 @@ public static void main(String[] args) throws Exception {
3538
private static void help(File jarFile, String message) {
3639
System.err.println(message + ".");
3740
System.out.println();
38-
System.out.println("Usage: java -jar " + jarFile.getName() + " <pid> [<secrets_file>]");
41+
System.out.println(
42+
"Usage: java -jar " + jarFile.getName() + " attach <pid> [<secrets_file>]");
43+
System.out.println(" java -jar " + jarFile.getName() + " detach <pid>");
3944
System.out.println(" java -jar " + jarFile.getName() + " list");
4045
System.out.println();
4146
System.out.println("Options:");
47+
System.out.println(" * attach - start logging secrets for the given process");
48+
System.out.println(" * detach - stop logging secrets for the given process");
4249
System.out.println(" * list - shows available Java processes to attach to");
4350
System.out.println(" * pid - the process ID to attach to (required)");
4451
System.out.println(" * secrets_file - file path to log the shared secrets to (optional);");

src/main/java/name/neykov/secrets/cli/AttachHelper.java

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,12 @@ public static void handle(String jarPath, String pid, String attachOptions)
3030
System.out.print(AttachHelper.list());
3131
} else {
3232
try {
33-
AttachHelper.attach(pid, jarPath, attachOptions);
34-
System.out.println("Successfully attached to process ID " + pid + ".");
33+
AttachHelper.loadagent(pid, jarPath, attachOptions);
34+
if ("detach".equals(attachOptions)) {
35+
System.out.println("Successfully detached from process ID " + pid + ".");
36+
} else {
37+
System.out.println("Successfully attached to process ID " + pid + ".");
38+
}
3539
} catch (IllegalStateException e) {
3640
String msg =
3741
e.getMessage() != null
@@ -42,7 +46,7 @@ public static void handle(String jarPath, String pid, String attachOptions)
4246
}
4347
}
4448

45-
private static void attach(String pid, String jarPath, String options) {
49+
private static void loadagent(String pid, String jarPath, String options) {
4650
try {
4751
VirtualMachine vm = VirtualMachine.attach(pid);
4852
vm.loadAgent(jarPath, options);

src/main/java/name/neykov/secrets/cli/CliArguments.java

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
package name.neykov.secrets.cli;
22

33
class CliArguments {
4-
final String listOrPid;
4+
final String action;
5+
6+
final String pid;
57

68
final String secretsPath;
79

8-
CliArguments(String listOrPid, String secretsPath) {
9-
this.listOrPid = listOrPid;
10+
CliArguments(String action, String pid, String secretsPath) {
11+
this.action = action;
12+
this.pid = pid;
1013
this.secretsPath = secretsPath;
1114
}
1215

@@ -18,7 +21,19 @@ static CliArguments parse(String[] args) {
1821
if (args.length > 1) {
1922
throw new IllegalArgumentException("'list' action does not take any arguments");
2023
}
21-
return new CliArguments("list", "");
24+
return new CliArguments("list", null, "");
25+
} else if ("detach".equals(args[0])) {
26+
if (args.length != 2) {
27+
throw new IllegalArgumentException(
28+
"'detach' action requires exactly one argument: the process ID");
29+
}
30+
return new CliArguments("detach", args[1], "");
31+
} else if ("attach".equals(args[0])) {
32+
if (args.length < 2 || args.length > 3) {
33+
throw new IllegalArgumentException(
34+
"'attach' action requires a process ID and an optional secrets file path");
35+
}
36+
return new CliArguments("attach", args[1], args.length == 3 ? args[2] : "");
2237
} else {
2338
String pid = null;
2439
String secretPath = null;
@@ -44,7 +59,7 @@ static CliArguments parse(String[] args) {
4459
secretPath = "";
4560
}
4661

47-
return new CliArguments(pid, secretPath);
62+
return new CliArguments("attach", pid, secretPath);
4863
}
4964
}
5065

@@ -59,7 +74,10 @@ public boolean equals(Object o) {
5974

6075
CliArguments that = (CliArguments) o;
6176

62-
if (!listOrPid.equals(that.listOrPid)) {
77+
if (!action.equals(that.action)) {
78+
return false;
79+
}
80+
if (pid != null ? !pid.equals(that.pid) : that.pid != null) {
6381
return false;
6482
}
6583
return secretsPath.equals(that.secretsPath);
@@ -68,8 +86,11 @@ public boolean equals(Object o) {
6886
@Override
6987
public String toString() {
7088
return "CliArguments{"
71-
+ "listOrPid='"
72-
+ listOrPid
89+
+ "action='"
90+
+ action
91+
+ '\''
92+
+ ", pid='"
93+
+ pid
7394
+ '\''
7495
+ ", secretsPath='"
7596
+ secretsPath

src/test/docker/test.sh

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,34 @@ check_provider_logs() {
143143
fi
144144
}
145145

146+
# Run a single client connection without pcap or key assertions.
147+
run_client() {
148+
local proto="$1" cp="$2" provider="$3"
149+
docker run --network ssl-secrets --rm \
150+
-v $ROOT:/project -v $SECRETS_VOLUME:/secrets \
151+
ssl-secrets-server java -cp "$cp" \
152+
-Djavax.net.ssl.trustStoreType=jks \
153+
-Djavax.net.ssl.trustStore=/secrets/truststore \
154+
-Djavax.net.ssl.trustStorePassword=password \
155+
-Djdk.tls.client.protocols=$proto \
156+
-Dprovider=$provider \
157+
name.neykov.secrets.TestClient https://ssl-secrets-server/secret.txt
158+
}
159+
160+
assert_has_keys() {
161+
local file="$1"
162+
[ -s "$file" ] || { echo "Expected keys in $file but file is empty or absent" >&2; return 1; }
163+
}
164+
165+
assert_no_keys() {
166+
local file="$1"
167+
if [ -s "$file" ]; then
168+
echo "Expected no keys in $file but file contains:" >&2
169+
cat "$file" >&2
170+
return 1
171+
fi
172+
}
173+
146174
# Verify that a captured pcap can be decrypted using the given keylog file.
147175
check_decryptable() {
148176
local keyfile="$1"
@@ -385,3 +413,132 @@ run_ibm_jdk8_tests() {
385413
run_ibm_jdk8_tests
386414

387415
docker rm -f ssl-secrets-server
416+
417+
# ══════════════════════════════════════════════════════════════════════════════
418+
# Detach/re-attach test: attach → detach → re-attach lifecycle
419+
# Verifies that secrets stop being logged after detach and resume after
420+
# re-attach. Also exercises the double-attach and detach-without-attach guards.
421+
# Run once on a stable LTS; the detach logic is JVM-version-independent.
422+
# ══════════════════════════════════════════════════════════════════════════════
423+
424+
run_detach_test() {
425+
local java_version="$1"
426+
427+
echo -e "\n" \
428+
"=============================================\n" \
429+
" Detach/re-attach - Java $java_version \n" \
430+
"=============================================\n\n"
431+
432+
docker rm -f $(docker ps -qa) 2>/dev/null || true
433+
docker build -f $CWD/Dockerfile.server $CWD -t ssl-secrets-server \
434+
--build-arg JAVA_IMAGE_TAG=$java_version
435+
436+
local cp="$DEFAULT_CP"
437+
local provider="JSSE"
438+
local proto="TLSv1.2"
439+
440+
# Start server without agent.
441+
docker run -d --name ssl-secrets-server --network ssl-secrets \
442+
-v $ROOT:/project \
443+
-v $SECRETS_VOLUME:/secrets \
444+
ssl-secrets-server java -cp "$cp" \
445+
-Dprovider=$provider \
446+
-Dkeystore.file=/secrets/keystore \
447+
name.neykov.secrets.TestServer
448+
wait_for_log ssl-secrets-server "server ready"
449+
450+
# ── Attach ───────────────────────────────────────────────────────────────
451+
rm -f $SECRETS_VOLUME/server.keys
452+
docker exec ssl-secrets-server java -jar /project/$JAR_PATH 1 /secrets/server.keys
453+
check_provider_logs "$provider" "attach"
454+
455+
# ── 1. Secrets captured after attach ─────────────────────────────────────
456+
run_client "$proto" "$cp" "$provider"
457+
assert_has_keys $SECRETS_VOLUME/server.keys
458+
459+
# ── 2. Double-attach guard: second attach logs "Already attached" ─────────
460+
docker exec ssl-secrets-server java -jar /project/$JAR_PATH 1 /secrets/server.keys
461+
wait_for_log ssl-secrets-server "Already attached"
462+
463+
# ── 3. Detach: secrets NOT captured for subsequent connections ────────────
464+
docker exec ssl-secrets-server java -jar /project/$JAR_PATH detach 1
465+
wait_for_log ssl-secrets-server "Successfully detached agent"
466+
467+
rm -f $SECRETS_VOLUME/server.keys
468+
run_client "$proto" "$cp" "$provider"
469+
assert_no_keys $SECRETS_VOLUME/server.keys
470+
471+
# ── 4. Detach-without-attach guard: second detach logs "Not attached" ─────
472+
docker exec ssl-secrets-server java -jar /project/$JAR_PATH detach 1
473+
wait_for_log ssl-secrets-server "Not attached"
474+
475+
# ── 5. Re-attach: secrets captured again ─────────────────────────────────
476+
docker exec ssl-secrets-server java -jar /project/$JAR_PATH 1 /secrets/server.keys
477+
rm -f $SECRETS_VOLUME/server.keys
478+
run_client "$proto" "$cp" "$provider"
479+
assert_has_keys $SECRETS_VOLUME/server.keys
480+
}
481+
482+
run_detach_test 21
483+
484+
docker rm -f ssl-secrets-server
485+
486+
# ══════════════════════════════════════════════════════════════════════════════
487+
# Premain-detach test: -javaagent load → runtime detach lifecycle
488+
# Verifies that an agent loaded via -javaagent: can be detached at runtime.
489+
# The Instrumentation object passed to premain is different from the one
490+
# passed to agentmain; this exercises the cross-instance removeTransformer
491+
# + retransformClasses path.
492+
# ══════════════════════════════════════════════════════════════════════════════
493+
494+
run_premain_detach_test() {
495+
local java_version="$1"
496+
497+
echo -e "\n" \
498+
"=============================================\n" \
499+
" Premain detach - Java $java_version \n" \
500+
"=============================================\n\n"
501+
502+
docker rm -f $(docker ps -qa) 2>/dev/null || true
503+
docker build -f $CWD/Dockerfile.server $CWD -t ssl-secrets-server \
504+
--build-arg JAVA_IMAGE_TAG=$java_version
505+
506+
local cp="$DEFAULT_CP"
507+
local provider="JSSE"
508+
local proto="TLSv1.2"
509+
510+
# Start server with -javaagent (premain path).
511+
rm -f $SECRETS_VOLUME/server.keys
512+
docker run -d --name ssl-secrets-server --network ssl-secrets \
513+
-v $ROOT:/project \
514+
-v $SECRETS_VOLUME:/secrets \
515+
ssl-secrets-server java -cp "$cp" \
516+
-Dprovider=$provider \
517+
-Dkeystore.file=/secrets/keystore \
518+
-javaagent:/project/$JAR_PATH=/secrets/server.keys \
519+
name.neykov.secrets.TestServer
520+
wait_for_log ssl-secrets-server "server ready"
521+
check_provider_logs "$provider" "agent"
522+
523+
# ── 1. Secrets captured with premain agent ────────────────────────────────
524+
run_client "$proto" "$cp" "$provider"
525+
assert_has_keys $SECRETS_VOLUME/server.keys
526+
527+
# ── 2. Runtime detach stops capture ──────────────────────────────────────
528+
docker exec ssl-secrets-server java -jar /project/$JAR_PATH detach 1
529+
wait_for_log ssl-secrets-server "Successfully detached agent"
530+
531+
rm -f $SECRETS_VOLUME/server.keys
532+
run_client "$proto" "$cp" "$provider"
533+
assert_no_keys $SECRETS_VOLUME/server.keys
534+
535+
# ── 3. Re-attach resumes capture ─────────────────────────────────────────
536+
docker exec ssl-secrets-server java -jar /project/$JAR_PATH 1 /secrets/server.keys
537+
rm -f $SECRETS_VOLUME/server.keys
538+
run_client "$proto" "$cp" "$provider"
539+
assert_has_keys $SECRETS_VOLUME/server.keys
540+
}
541+
542+
run_premain_detach_test 21
543+
544+
docker rm -f ssl-secrets-server

src/test/docker/test_errors.sh

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,28 @@ OUT=$(docker run --rm --network none \
5252

5353
[[ "$OUT" == *"Invalid JAVA_HOME environment variable"* ]] || exit 1
5454
[[ "$OUT" == *"Must point to a local JDK installation containing a 'lib/tools.jar'"* ]] || exit 1
55+
56+
# 'detach' with no PID argument
57+
OUT=$(docker run --rm --network none \
58+
-v $ROOT:/project \
59+
azul/zulu-openjdk:8 \
60+
java -jar /project/$JAR_PATH detach 2>&1)
61+
62+
[[ "$OUT" == *"'detach' action requires exactly one argument"* ]] || exit 1
63+
[[ "$OUT" == *"Usage"* ]] || exit 1
64+
65+
# 'detach' with extra argument
66+
OUT=$(docker run --rm --network none \
67+
-v $ROOT:/project \
68+
azul/zulu-openjdk:8 \
69+
java -jar /project/$JAR_PATH detach 1234 extra 2>&1)
70+
71+
[[ "$OUT" == *"'detach' action requires exactly one argument"* ]] || exit 1
72+
73+
# 'detach' against a non-existent PID
74+
OUT=$(docker run --rm --network none \
75+
-v $ROOT:/project \
76+
azul/zulu-openjdk:8 \
77+
java -jar /project/$JAR_PATH detach 99999 2>&1)
78+
79+
[[ "$OUT" == *"Failed to attach to java process 99999"* ]] || exit 1

0 commit comments

Comments
 (0)