From 67eb249e756a8793668343144d8a73a3161d0fb5 Mon Sep 17 00:00:00 2001
From: Valery Kharseko <vharseko@3a-systems.ru>
Date: Fri, 25 Sep 2026 09:21:06 +0000
Subject: [PATCH] [#1084] Keep the root password out of the log and off the command line when a Docker container joins replication (#1094)

---
 opendj-packages/opendj-docker/run.sh                 |    4 +
 .github/workflows/build.yml                          |   94 +++++++++++++++++++++++++++++++
 opendj-packages/opendj-docker/bootstrap/replicate.sh |   57 +++++++++++++-----
 3 files changed, 138 insertions(+), 17 deletions(-)

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 7162289..8469aec 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -556,6 +556,53 @@
           timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_uid | grep -q \"healthy\"; do sleep 10; done'
           docker exec test_uid 'sh' '-c' '/opt/opendj/bin/ldapsearch --hostname localhost --port 1636 --bindDN "cn=Directory Manager" --bindPassword password --useSsl --trustAll --baseDN "dc=example,dc=com" --searchScope base "(objectClass=*)" 1.1'
           docker kill test_uid
+      - name: Docker test replication
+        shell: bash
+        run: |
+          IMAGE=localhost:5000/${GITHUB_REPOSITORY,,}:${{ env.release_version }}
+          REPLICAS="test_replica test_replica_sdsr"
+          cleanup() { docker rm -f test_master $REPLICAS >/dev/null 2>&1 || true; docker network rm test_replication >/dev/null 2>&1 || true; }
+          cleanup
+          trap 'code=$?; for c in test_master $REPLICAS; do echo "::group::container logs ($c)"; docker logs $c 2>&1 || true; echo "::endgroup::"; done; cleanup; exit $code' ERR
+          # every tool reads the root password from a file (#1084); dsreplication run with -n prints
+          # no command line, so a password put back on one would pass every check below
+          rc=0; docker run --rm --entrypoint grep "$IMAGE" -nE -- '(^|[[:space:]])(-w|--(bindPassword[12]?|adminPassword))([[:space:]=]|$)' /opt/opendj/bootstrap/replicate.sh || rc=$?
+          if [ $rc -ne 1 ]; then echo "::error::replicate.sh passes the root password on a command line, or grep could not read it"; false; fi
+          # the password file goes to /dev/shm, off the writable layer of the container, and the mktemp of the image puts it there
+          docker run --rm --entrypoint grep "$IMAGE" -qF -- 'mktemp -p /dev/shm opendj-replicate.' /opt/opendj/bootstrap/replicate.sh || { echo "::error::replicate.sh no longer puts the password file on /dev/shm"; false; }
+          docker run --rm --entrypoint sh "$IMAGE" -c 'f=$(mktemp -p /dev/shm opendj-replicate.XXXXXX) && rm -f "$f" && case $f in /dev/shm/opendj-replicate.*) ;; *) exit 1;; esac' || { echo "::error::mktemp in the image does not create the password file on /dev/shm"; false; }
+          # a password with a space in it reaches every tool as one value
+          ROOT_PASSWORD='replication secret'
+          docker network create test_replication
+          docker run --rm -it -d --memory="512m" --network test_replication --ipc=shareable --name=test_master --hostname=dj-master -e ADD_BASE_ENTRY="--addBaseEntry" -e ROOT_PASSWORD="$ROOT_PASSWORD" "$IMAGE"
+          timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_master | grep -q \"healthy\"; do sleep 10; done'
+          # a replica reports itself healthy only once replicate.sh has succeeded; the sdsr replica joins after
+          # the simple one, as two dsreplication enable at once would both rewrite the admin data of the master
+          # a Kubernetes pod keeps its /dev/shm across container restarts: the replica shares the /dev/shm of the master, where
+          # a password file waits as a killed replicate.sh would have left it, and its run.sh has to remove it (checked below)
+          docker exec test_master sh -c 'printf "%s\n" "$ROOT_PASSWORD" >/dev/shm/opendj-replicate.killed'
+          docker run --rm -it -d --memory="512m" --network test_replication --ipc=container:test_master --name=test_replica --hostname=dj-replica -e ROOT_PASSWORD="$ROOT_PASSWORD" -e MASTER_SERVER=dj-master -e OPENDJ_REPLICATION_TYPE=simple "$IMAGE"
+          timeout 5m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_replica | grep -q \"healthy\"; do sleep 10; done'
+          docker run --rm -it -d --memory="512m" --network test_replication --name=test_replica_sdsr --hostname=dj-replica-sdsr -e ROOT_PASSWORD="$ROOT_PASSWORD" -e MASTER_SERVER=dj-master -e OPENDJ_REPLICATION_TYPE=sdsr "$IMAGE"
+          timeout 5m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_replica_sdsr | grep -q \"healthy\"; do sleep 10; done'
+          # the replicas were initialized from the master, and a change made on the master reaches them
+          for c in $REPLICAS; do
+            docker exec $c /opt/opendj/bin/ldapsearch --hostname localhost --port 1636 --bindDN "cn=Directory Manager" --bindPassword "$ROOT_PASSWORD" --useSsl --trustAll --baseDN "dc=example,dc=com" --searchScope base "(objectClass=*)" 1.1
+          done
+          printf 'dn: ou=replicated,dc=example,dc=com\nobjectClass: organizationalUnit\nou: replicated\n' | docker exec -i test_master /opt/opendj/bin/ldapmodify --hostname localhost --port 1636 --bindDN "cn=Directory Manager" --bindPassword "$ROOT_PASSWORD" --useSsl --trustAll --defaultAdd
+          for c in $REPLICAS; do
+            timeout 1m bash -c 'until docker exec $1 /opt/opendj/bin/ldapsearch --hostname localhost --port 1636 --bindDN "cn=Directory Manager" --bindPassword "$0" --useSsl --trustAll --baseDN "ou=replicated,dc=example,dc=com" --searchScope base "(objectClass=*)" 1.1; do sleep 5; done' "$ROOT_PASSWORD" $c
+          done
+          # the root password shows in no container log, and the file replicate.sh passed it in is gone (#1084)
+          for c in test_master $REPLICAS; do
+            if docker logs $c 2>&1 | grep -F "$ROOT_PASSWORD"; then echo "::error::The root password is in the log of $c"; false; fi
+          done
+          for c in $REPLICAS; do
+            # the JVM of the HEALTHCHECK's ldapsearch keeps its command line, root password included, in /tmp/hsperfdata_* while it runs
+            left=$(docker exec $c grep -rlsF -- "$ROOT_PASSWORD" /tmp /dev/shm | grep -v '^/tmp/hsperfdata_' || true)
+            if [ -n "$left" ]; then echo "::error::The root password is left in $left of $c"; false; fi
+          done
+          cleanup
       - name: Scan image for vulnerabilities (Trivy)
         # trivy resolves the image from the local Docker daemon, so only the runner's
         # linux/amd64 manifest is scanned; cache: false keeps the ~1GB trivy DBs from
@@ -722,6 +769,53 @@
           timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_uid | grep -q \"healthy\"; do sleep 10; done'
           docker exec test_uid 'sh' '-c' '/opt/opendj/bin/ldapsearch --hostname localhost --port 1636 --bindDN "cn=Directory Manager" --bindPassword password --useSsl --trustAll --baseDN "dc=example,dc=com" --searchScope base "(objectClass=*)" 1.1'
           docker kill test_uid
+      - name: Docker test replication
+        shell: bash
+        run: |
+          IMAGE=localhost:5000/${GITHUB_REPOSITORY,,}:${{ env.release_version }}-alpine
+          REPLICAS="test_replica test_replica_sdsr"
+          cleanup() { docker rm -f test_master $REPLICAS >/dev/null 2>&1 || true; docker network rm test_replication >/dev/null 2>&1 || true; }
+          cleanup
+          trap 'code=$?; for c in test_master $REPLICAS; do echo "::group::container logs ($c)"; docker logs $c 2>&1 || true; echo "::endgroup::"; done; cleanup; exit $code' ERR
+          # every tool reads the root password from a file (#1084); dsreplication run with -n prints
+          # no command line, so a password put back on one would pass every check below
+          rc=0; docker run --rm --entrypoint grep "$IMAGE" -nE -- '(^|[[:space:]])(-w|--(bindPassword[12]?|adminPassword))([[:space:]=]|$)' /opt/opendj/bootstrap/replicate.sh || rc=$?
+          if [ $rc -ne 1 ]; then echo "::error::replicate.sh passes the root password on a command line, or grep could not read it"; false; fi
+          # the password file goes to /dev/shm, off the writable layer of the container, and the mktemp of the image puts it there
+          docker run --rm --entrypoint grep "$IMAGE" -qF -- 'mktemp -p /dev/shm opendj-replicate.' /opt/opendj/bootstrap/replicate.sh || { echo "::error::replicate.sh no longer puts the password file on /dev/shm"; false; }
+          docker run --rm --entrypoint sh "$IMAGE" -c 'f=$(mktemp -p /dev/shm opendj-replicate.XXXXXX) && rm -f "$f" && case $f in /dev/shm/opendj-replicate.*) ;; *) exit 1;; esac' || { echo "::error::mktemp in the image does not create the password file on /dev/shm"; false; }
+          # a password with a space in it reaches every tool as one value
+          ROOT_PASSWORD='replication secret'
+          docker network create test_replication
+          docker run --rm -it -d --memory="1g" --network test_replication --ipc=shareable --name=test_master --hostname=dj-master -e ADD_BASE_ENTRY="--addBaseEntry" -e ROOT_PASSWORD="$ROOT_PASSWORD" "$IMAGE"
+          timeout 3m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_master | grep -q \"healthy\"; do sleep 10; done'
+          # a replica reports itself healthy only once replicate.sh has succeeded; the sdsr replica joins after
+          # the simple one, as two dsreplication enable at once would both rewrite the admin data of the master
+          # a Kubernetes pod keeps its /dev/shm across container restarts: the replica shares the /dev/shm of the master, where
+          # a password file waits as a killed replicate.sh would have left it, and its run.sh has to remove it (checked below)
+          docker exec test_master sh -c 'printf "%s\n" "$ROOT_PASSWORD" >/dev/shm/opendj-replicate.killed'
+          docker run --rm -it -d --memory="1g" --network test_replication --ipc=container:test_master --name=test_replica --hostname=dj-replica -e ROOT_PASSWORD="$ROOT_PASSWORD" -e MASTER_SERVER=dj-master -e OPENDJ_REPLICATION_TYPE=simple "$IMAGE"
+          timeout 5m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_replica | grep -q \"healthy\"; do sleep 10; done'
+          docker run --rm -it -d --memory="1g" --network test_replication --name=test_replica_sdsr --hostname=dj-replica-sdsr -e ROOT_PASSWORD="$ROOT_PASSWORD" -e MASTER_SERVER=dj-master -e OPENDJ_REPLICATION_TYPE=sdsr "$IMAGE"
+          timeout 5m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_replica_sdsr | grep -q \"healthy\"; do sleep 10; done'
+          # the replicas were initialized from the master, and a change made on the master reaches them
+          for c in $REPLICAS; do
+            docker exec $c /opt/opendj/bin/ldapsearch --hostname localhost --port 1636 --bindDN "cn=Directory Manager" --bindPassword "$ROOT_PASSWORD" --useSsl --trustAll --baseDN "dc=example,dc=com" --searchScope base "(objectClass=*)" 1.1
+          done
+          printf 'dn: ou=replicated,dc=example,dc=com\nobjectClass: organizationalUnit\nou: replicated\n' | docker exec -i test_master /opt/opendj/bin/ldapmodify --hostname localhost --port 1636 --bindDN "cn=Directory Manager" --bindPassword "$ROOT_PASSWORD" --useSsl --trustAll --defaultAdd
+          for c in $REPLICAS; do
+            timeout 1m bash -c 'until docker exec $1 /opt/opendj/bin/ldapsearch --hostname localhost --port 1636 --bindDN "cn=Directory Manager" --bindPassword "$0" --useSsl --trustAll --baseDN "ou=replicated,dc=example,dc=com" --searchScope base "(objectClass=*)" 1.1; do sleep 5; done' "$ROOT_PASSWORD" $c
+          done
+          # the root password shows in no container log, and the file replicate.sh passed it in is gone (#1084)
+          for c in test_master $REPLICAS; do
+            if docker logs $c 2>&1 | grep -F "$ROOT_PASSWORD"; then echo "::error::The root password is in the log of $c"; false; fi
+          done
+          for c in $REPLICAS; do
+            # the JVM of the HEALTHCHECK's ldapsearch keeps its command line, root password included, in /tmp/hsperfdata_* while it runs
+            left=$(docker exec $c grep -rlsF -- "$ROOT_PASSWORD" /tmp /dev/shm | grep -v '^/tmp/hsperfdata_' || true)
+            if [ -n "$left" ]; then echo "::error::The root password is left in $left of $c"; false; fi
+          done
+          cleanup
       - name: Scan image for vulnerabilities (Trivy)
         # trivy resolves the image from the local Docker daemon, so only the runner's
         # linux/amd64 manifest is scanned; cache: false keeps the ~1GB trivy DBs from
diff --git a/opendj-packages/opendj-docker/bootstrap/replicate.sh b/opendj-packages/opendj-docker/bootstrap/replicate.sh
index f1c63a5..e996e6f 100755
--- a/opendj-packages/opendj-docker/bootstrap/replicate.sh
+++ b/opendj-packages/opendj-docker/bootstrap/replicate.sh
@@ -1,4 +1,18 @@
 #!/usr/bin/env bash
+# The contents of this file are subject to the terms of the Common Development and
+# Distribution License (the License). You may not use this file except in compliance with the
+# License.
+#
+# You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+# specific language governing permission and limitations under the License.
+#
+# When distributing Covered Software, include this CDDL Header Notice in each file and include
+# the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+# Header, with the fields enclosed by brackets [] replaced by your own identifying
+# information: "Portions copyright [year] [name of copyright owner]".
+#
+# Portions Copyright 2026 3A Systems, LLC.
+
 # Replicate to the master server hostname defined in $1
 # If that server is ourself this is a no-op
 
@@ -19,9 +33,18 @@
   exit 0
 fi
 
-# Comment out
-echo "replicate ENV vars:"
-env
+echo "Replication type: $OPENDJ_REPLICATION_TYPE, base DN: $BASE_DN"
+
+# The tools read the root password from a file, so that it shows neither in the container log
+# nor on the command line of a process while the tool runs. mktemp creates the file readable by
+# its owner only. It goes to the tmpfs of /dev/shm where there is one: the EXIT trap does not
+# run if the script is killed, and a file left in /tmp would stay in the writable layer of the
+# container, where no later start removes it, since replicate.sh runs only on the first one.
+# On Kubernetes /dev/shm belongs to the pod: it is shared by all its containers and outlives a
+# restart of this one, so run.sh removes a file left there by name on every start.
+PASSWORD_FILE=$(mktemp -p /dev/shm opendj-replicate.XXXXXX 2>/dev/null || mktemp) || exit 1
+trap 'rm -f "$PASSWORD_FILE"' EXIT
+printf '%s\n' "$ROOT_PASSWORD" >"$PASSWORD_FILE" || exit 1
 
 # todo: Replace with command to test for master being reachable and up
 # This is hacky....
@@ -36,17 +59,17 @@
     --host1 $MASTER_SERVER \
     --port1 4444 \
     --bindDN1 "$ROOT_USER_DN" \
-    --bindPassword1 "$ROOT_PASSWORD" --replicationPort1 8989 \
+    --bindPasswordFile1 "$PASSWORD_FILE" --replicationPort1 8989 \
     --host2 $MYHOSTNAME --port2 4444 --bindDN2 "$ROOT_USER_DN" \
-    --bindPassword2 "$ROOT_PASSWORD" --replicationPort2 8989 \
-    --adminUID admin --adminPassword "$ROOT_PASSWORD" \
+    --bindPasswordFile2 "$PASSWORD_FILE" --replicationPort2 8989 \
+    --adminUID admin --adminPasswordFile "$PASSWORD_FILE" \
     --baseDN "$BASE_DN" -X -n
 
   echo "initializing replication"
 
   # replicating data in MASTER_SERVER to MYHOSTNAME:
   /opt/opendj/bin/dsreplication initialize --baseDN "$BASE_DN" \
-    --adminUID admin --adminPassword "$ROOT_PASSWORD" \
+    --adminUID admin --adminPasswordFile "$PASSWORD_FILE" \
     --hostSource $MASTER_SERVER --portSource 4444 \
     --hostDestination $MYHOSTNAME --portDestination 4444 -X -n
 
@@ -54,17 +77,17 @@
   echo "Enabling Standalone Replication Servers..."
   dsreplication enable \
     --adminUID admin \
-    --adminPassword "$ROOT_PASSWORD" \
+    --adminPasswordFile "$PASSWORD_FILE" \
     --baseDN "$BASE_DN" \
     --host1 $MYHOSTNAME \
     --port1 4444 \
     --bindDN1 "$ROOT_USER_DN" \
-    --bindPassword1 "$ROOT_PASSWORD" \
+    --bindPasswordFile1 "$PASSWORD_FILE" \
     --noReplicationServer1 \
     --host2 $MASTER_SERVER \
     --port2 4444 \
     --bindDN2 "$ROOT_USER_DN" \
-    --bindPassword2 "$ROOT_PASSWORD" \
+    --bindPasswordFile2 "$PASSWORD_FILE" \
     --replicationPort2 8989 \
     --onlyReplicationServer2 \
     --trustAll \
@@ -75,7 +98,7 @@
   dsreplication \
     initialize-all \
     --adminUID admin \
-    --adminPassword "$ROOT_PASSWORD" \
+    --adminPasswordFile "$PASSWORD_FILE" \
     --baseDN "$BASE_DN" \
     --hostname $MYHOSTNAME \
     --port 4444 \
@@ -87,16 +110,16 @@
   dsreplication \
     enable \
     --adminUID admin \
-    --adminPassword "$ROOT_PASSWORD" \
+    --adminPasswordFile "$PASSWORD_FILE" \
     --baseDN "$BASE_DN" \
     --host1 $MASTER_SERVER \
     --port1 4444 \
     --bindDN1 "$ROOT_USER_DN" \
-    --bindPassword1 "$ROOT_PASSWORD" \
+    --bindPasswordFile1 "$PASSWORD_FILE" \
     --host2 $MYHOSTNAME \
     --port2 4444 \
     --bindDN2 "$ROOT_USER_DN" \
-    --bindPassword2 "$ROOT_PASSWORD" \
+    --bindPasswordFile2 "$PASSWORD_FILE" \
     --noReplicationServer2 \
     --trustAll \
     --no-prompt
@@ -106,7 +129,7 @@
   dsreplication \
     initialize \
     --adminUID admin \
-    --adminPassword "$ROOT_PASSWORD" \
+    --adminPasswordFile "$PASSWORD_FILE" \
     --baseDN "$BASE_DN" \
     --hostSource $MASTER_SERVER \
     --portSource 4444 \
@@ -123,7 +146,7 @@
     --port 4444 \
     --hostname $MYHOSTNAME \
     --bindDN "$ROOT_USER_DN" \
-    --bindPassword "$ROOT_PASSWORD" \
+    --bindPasswordFile "$PASSWORD_FILE" \
     --provider-name "Multimaster Synchronization" \
     --domain-name "$BASE_DN" \
     --set group-id:$OPENDJ_REPLICATION_GROUP_ID \
@@ -135,7 +158,7 @@
     --port 4444 \
     --hostname $MASTER_SERVER \
     --bindDN "$ROOT_USER_DN" \
-    --bindPassword "$ROOT_PASSWORD" \
+    --bindPasswordFile "$PASSWORD_FILE" \
     --provider-name "Multimaster Synchronization" \
     --set group-id:$OPENDJ_REPLICATION_GROUP_ID \
     --trustAll \
diff --git a/opendj-packages/opendj-docker/run.sh b/opendj-packages/opendj-docker/run.sh
index 9e028b2..d1ca2bd 100755
--- a/opendj-packages/opendj-docker/run.sh
+++ b/opendj-packages/opendj-docker/run.sh
@@ -34,6 +34,10 @@
 BOOTSTRAP_COMPLETE=${BOOTSTRAP_COMPLETE:-/opt/opendj/.bootstrap-complete}
 rm -f "$BOOTSTRAP_COMPLETE"
 
+# A replicate.sh killed before its EXIT trap ran leaves the root password in /dev/shm, and on
+# Kubernetes that outlives the container: the pod keeps its /dev/shm across container restarts
+rm -f /dev/shm/opendj-replicate.*
+
 #if default data folder exists do not change it
 if [ ! -d ./db ]; then
   echo "/opt/opendj/data" >/opt/opendj/instance.loc && \

--
Gitblit v1.10.0