Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions dream-server/.env.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@
},
"N8N_USER": {
"type": "string",
"description": "n8n initial admin email address"
"description": "n8n initial admin email address",
"secret": true
},
"N8N_PASS": {
"type": "string",
Expand Down Expand Up @@ -502,7 +503,8 @@
"LANGFUSE_INIT_USER_EMAIL": {
"type": "string",
"description": "Langfuse initial admin user email",
"default": "admin@dreamserver.local"
"default": "admin@dreamserver.local",
"secret": true
},
"LANGFUSE_INIT_USER_PASSWORD": {
"type": "string",
Expand Down
3 changes: 3 additions & 0 deletions dream-server/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ test: ## Run unit and contract tests
@echo "=== Overlay/plist contracts ==="
@bash tests/contracts/test-overlay-map-coherence.sh
@bash tests/contracts/test-plist-log-paths.sh
@echo ""
@echo "=== dream config show secret-mask matrix ==="
@bash tests/test-dream-config-secret-mask.sh

bats: ## Run BATS unit tests for shell libraries
@echo "=== BATS unit tests ==="
Expand Down
101 changes: 93 additions & 8 deletions dream-server/dream-cli
Original file line number Diff line number Diff line change
Expand Up @@ -1174,6 +1174,77 @@ cmd_shell() {
fi
}

# File-scope helpers used by `dream config show` and `dream preset diff` to
# mask secret values. `.env.schema.json` is the authoritative source; keys
# marked `"secret": true` are treated as secrets. A keyword fallback covers
# schema gaps (e.g. ANTHROPIC_API_KEY is currently secret:false) and the case
# where the schema file or jq is unavailable. Callers must invoke
# _cmd_config_load_secret_schema once (after check_install, so INSTALL_DIR is
# set) before calling _cmd_config_is_secret.
_cmd_config_secret_keys=()
_cmd_config_schema_loaded=0

_cmd_config_load_secret_schema() {
local _schema_path="$INSTALL_DIR/.env.schema.json"
_cmd_config_secret_keys=()
_cmd_config_schema_loaded=0
[[ -f "$_schema_path" ]] || return 0

# Prefer jq when available — fast, single-process, schema-typed.
if command -v jq >/dev/null 2>&1; then
mapfile -t _cmd_config_secret_keys < <(jq -r '.properties | to_entries[] | select(.value.secret == true) | .key' "$_schema_path" 2>/dev/null)
_cmd_config_schema_loaded=1
return 0
fi

# python3 fallback for environments without jq (Git Bash on Windows
# is the common one). The installer guarantees python3, so this
# path closes the gap where N8N_USER / LANGFUSE_INIT_USER_EMAIL etc.
# would otherwise leak through the keyword fallback.
if command -v python3 >/dev/null 2>&1; then
mapfile -t _cmd_config_secret_keys < <(python3 - "$_schema_path" <<'PYEOF' 2>/dev/null
import json, sys
try:
with open(sys.argv[1]) as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
sys.exit(0)
for key, spec in (data.get("properties") or {}).items():
if isinstance(spec, dict) and spec.get("secret") is True:
print(key)
PYEOF
)
_cmd_config_schema_loaded=1
return 0
fi

# Neither jq nor python3 — leave _cmd_config_schema_loaded=0 so
# _cmd_config_is_secret falls through to the keyword regex (which
# covers user/email keys explicitly to avoid leaks in this case).
}

_cmd_config_is_secret() {
local _k="$1" _s _kl
if (( _cmd_config_schema_loaded == 1 )); then
for _s in "${_cmd_config_secret_keys[@]}"; do
[[ "$_k" == "$_s" ]] && return 0
done
# Fall through to keyword match — defense in depth against schema gaps
# and against malformed schemas where jq/python returns zero secret keys.
fi
_kl="${_k,,}"
# `*user*` and `*email*` cover N8N_USER, LANGFUSE_INIT_USER_EMAIL, etc.
# in environments where neither jq nor python3 was available to read
# `.env.schema.json` (Git Bash without jq). Risks light over-masking
# on operational keys like USER_HOME, but `cat .env` remains the
# escape hatch — `dream config show` defaults to over-mask on
# purpose.
case "$_kl" in
*secret*|*password*|*pass*|*token*|*key*|*salt*|*bearer*|*user*|*email*) return 0 ;;
esac
return 1
}

cmd_config() {
check_install

Expand All @@ -1185,14 +1256,19 @@ cmd_config() {
echo "Install dir: $INSTALL_DIR"
echo ""
echo -e "${CYAN}.env contents:${NC}"
grep -v "^#" "$INSTALL_DIR/.env" | grep -v "^$" | while read line; do
# Hide sensitive values
if echo "$line" | grep -qiE "(secret|pass|token|key)="; then
echo " ${line%%=*}=***"

_cmd_config_load_secret_schema

local _line _key
while IFS= read -r _line; do
[[ -z "$_line" || "$_line" =~ ^[[:space:]]*# ]] && continue
_key="${_line%%=*}"
if _cmd_config_is_secret "$_key"; then
echo " ${_key}=***"
else
echo " $line"
echo " $_line"
fi
done
done < "$INSTALL_DIR/.env"
;;
edit)
${EDITOR:-nano} "$INSTALL_DIR/.env"
Expand All @@ -1201,7 +1277,11 @@ cmd_config() {
validate)
cd "$INSTALL_DIR"
if [[ -x "$INSTALL_DIR/scripts/validate-env.sh" ]]; then
"$INSTALL_DIR/scripts/validate-env.sh" "$INSTALL_DIR/.env" "$INSTALL_DIR/.env.schema.json"
# Invoke through "$BASH" (the currently-running shell, guaranteed
# Bash 4+ by the version check at the top of this file). The
# target script uses associative arrays (declare -A), which
# crash under macOS's system /bin/bash (3.2).
"$BASH" "$INSTALL_DIR/scripts/validate-env.sh" "$INSTALL_DIR/.env" "$INSTALL_DIR/.env.schema.json"
else
warn "validate-env.sh not found at $INSTALL_DIR/scripts/validate-env.sh"
warn "Make sure you're on a recent Dream Server release."
Expand Down Expand Up @@ -2083,6 +2163,11 @@ META
# Compare environment variables
echo -e "${CYAN}━━━ Environment Variables ━━━${NC}"
if [[ -f "$dir1/env" ]] && [[ -f "$dir2/env" ]]; then
# Load secret schema so `_cmd_config_is_secret` below can consult
# .env.schema.json instead of the narrow regex the old version
# used (which missed _PASS, _SALT, email admin fields, etc.).
_cmd_config_load_secret_schema

# Parse both env files
declare -A env1 env2
while IFS='=' read -r key value; do
Expand Down Expand Up @@ -2114,7 +2199,7 @@ META
has_diff=true

# Mask sensitive values for display
if [[ "$key" =~ (PASSWORD|SECRET|KEY|TOKEN|API) ]]; then
if _cmd_config_is_secret "$key"; then
[[ -n "$val1" ]] && val1="***"
[[ -n "$val2" ]] && val2="***"
fi
Expand Down
13 changes: 13 additions & 0 deletions dream-server/scripts/validate-env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@
# - Validate required keys, unknown keys, types, enums, and numeric ranges
# - Fail deterministically with a single exit code for CI

# Require Bash 4+ (associative arrays used below).
# macOS ships Bash 3.2 due to licensing; the system /bin/bash will crash on
# `declare -A`. When launched via dream-cli this is invoked through "$BASH",
# but this guard protects direct invocations (e.g. /bin/bash validate-env.sh).
if (( BASH_VERSINFO[0] < 4 )); then
echo "✗ validate-env.sh requires Bash 4+ (you have ${BASH_VERSION})" >&2
echo " macOS ships Bash 3.2 — install a modern Bash:" >&2
echo " brew install bash" >&2
echo " Then re-run with the Homebrew bash, e.g.:" >&2
echo " /opt/homebrew/bin/bash $0 $*" >&2
exit 1
fi

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
Expand Down
173 changes: 173 additions & 0 deletions dream-server/tests/test-dream-config-secret-mask.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
#!/usr/bin/env bash
# ============================================================================
# Regression: `dream config show` masks `N8N_USER` and
# `LANGFUSE_INIT_USER_EMAIL` even in environments without `jq`.
# ============================================================================
# Audit follow-up on PR #994 (Lightheartdevs, 2026-04-28):
#
# "Schema-driven secret masking is useful, but the CLI only learns
# the schema secret flags through `jq`. In Git Bash without `jq`,
# newly marked user/email fields such as `N8N_USER` and
# `LANGFUSE_INIT_USER_EMAIL` can still print in clear. Please either
# make schema parsing available without `jq` for this command or
# extend the fallback mask to cover the new schema secrets."
#
# Both fixes are now in place: a Python fallback parser when `jq` is
# absent, plus `*user*` / `*email*` keyword fallback when neither is
# present. This test exercises all three PATH configurations.
# ============================================================================

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
DREAM_CLI="$ROOT_DIR/dream-cli"

GREEN='\033[0;32m'
RED='\033[0;31m'
NC='\033[0m'

PASSED=0
FAILED=0

pass() { echo -e " ${GREEN}✓ PASS${NC} $1"; PASSED=$((PASSED + 1)); }
fail() { echo -e " ${RED}✗ FAIL${NC} $1"; FAILED=$((FAILED + 1)); }

echo ""
echo "╔═══════════════════════════════════════════════╗"
echo "║ dream config show — secret masking matrix ║"
echo "╚═══════════════════════════════════════════════╝"
echo ""

if [[ ! -x "$DREAM_CLI" ]]; then
fail "dream-cli not found at $DREAM_CLI"
echo ""; echo "Result: $PASSED passed, $FAILED failed"; exit 1
fi

# Scaffold a hermetic install dir. The schema marks N8N_USER and
# LANGFUSE_INIT_USER_EMAIL as secret:true; .env contains values that
# must NEVER appear in `dream config show` output.
TEMP_DIR=$(mktemp -d)
trap 'rm -rf "$TEMP_DIR"' EXIT

INSTALL_SCAFFOLD="$TEMP_DIR/install"
mkdir -p "$INSTALL_SCAFFOLD"
# check_install requires either docker-compose.base.yml or docker-compose.yml
touch "$INSTALL_SCAFFOLD/docker-compose.base.yml"

cat > "$INSTALL_SCAFFOLD/.env" <<'EOF'
# Test fixture
N8N_USER=actual-admin-username
LANGFUSE_INIT_USER_EMAIL=admin@example.test
DREAM_VERSION=2.0.0-test
HOST_RAM_GB=32
EOF

cat > "$INSTALL_SCAFFOLD/.env.schema.json" <<'EOF'
{
"properties": {
"N8N_USER": {"type": "string", "secret": true},
"LANGFUSE_INIT_USER_EMAIL": {"type": "string", "secret": true},
"DREAM_VERSION": {"type": "string"},
"HOST_RAM_GB": {"type": "string"}
}
}
EOF

# Sentinel values whose appearance in stdout would prove a leak.
SECRET_USER='actual-admin-username'
SECRET_EMAIL='admin@example.test'

run_dream_config_show() {
# Invokes dream-cli with a controlled PATH to simulate environments
# with/without jq + python3. NO_COLOR=1 keeps output ASCII.
local _path="$1"
local _label="$2"
local _output
_output=$(NO_COLOR=1 PATH="$_path" DREAM_HOME="$INSTALL_SCAFFOLD" \
"$BASH" "$DREAM_CLI" config show 2>&1)
echo "$_output"
}

# Discover real paths to bash, sed, awk, mktemp, etc. so the CLI runs.
# We strip jq and/or python3 from PATH by listing only their needed
# siblings. The simplest approach: build a path that excludes a
# specific binary by symlinking required binaries into a tempdir.
build_pathdir_excluding() {
# build_pathdir_excluding "<exclude1> <exclude2> ..."
local _excludes="$1"
local _pdir="$TEMP_DIR/pathdir-$RANDOM"
mkdir -p "$_pdir"
local _bin
# Tools dream-cli (the section we exercise) actually uses.
for _bin in bash sh ls cat grep sed awk tr cut sort head tail \
printf echo mkdir rm tee dirname basename pwd command \
python3 jq find env; do
local _real
_real="$(command -v "$_bin" 2>/dev/null || true)"
[[ -z "$_real" ]] && continue
# Skip excluded names.
case " $_excludes " in *" $_bin "*) continue ;; esac
ln -s "$_real" "$_pdir/$_bin"
done
echo "$_pdir"
}

# --- Case 1: jq + python3 both present (schema-driven path) ---
PATH_FULL=$(build_pathdir_excluding "")
out1=$(run_dream_config_show "$PATH_FULL" "full")
if grep -q "N8N_USER=\\*\\*\\*" <<<"$out1" && ! grep -qF "$SECRET_USER" <<<"$out1"; then
pass "with jq+python3: N8N_USER masked, value not leaked"
else
fail "with jq+python3: N8N_USER not masked correctly"
echo " --- output ---"; awk '{print " " $0}' <<<"$out1"
fi
if grep -q "LANGFUSE_INIT_USER_EMAIL=\\*\\*\\*" <<<"$out1" && ! grep -qF "$SECRET_EMAIL" <<<"$out1"; then
pass "with jq+python3: LANGFUSE_INIT_USER_EMAIL masked, value not leaked"
else
fail "with jq+python3: LANGFUSE_INIT_USER_EMAIL not masked correctly"
echo " --- output ---"; awk '{print " " $0}' <<<"$out1"
fi

# --- Case 2: no jq, python3 present (Git-Bash-without-jq simulation) ---
PATH_NO_JQ=$(build_pathdir_excluding "jq")
out2=$(run_dream_config_show "$PATH_NO_JQ" "no-jq")
if grep -q "N8N_USER=\\*\\*\\*" <<<"$out2" && ! grep -qF "$SECRET_USER" <<<"$out2"; then
pass "without jq: N8N_USER masked via python3 fallback"
else
fail "without jq: N8N_USER LEAKED — Git Bash regression"
echo " --- output ---"; awk '{print " " $0}' <<<"$out2"
fi
if grep -q "LANGFUSE_INIT_USER_EMAIL=\\*\\*\\*" <<<"$out2" && ! grep -qF "$SECRET_EMAIL" <<<"$out2"; then
pass "without jq: LANGFUSE_INIT_USER_EMAIL masked via python3 fallback"
else
fail "without jq: LANGFUSE_INIT_USER_EMAIL LEAKED — Git Bash regression"
echo " --- output ---"; awk '{print " " $0}' <<<"$out2"
fi

# --- Case 3: neither jq nor python3 (keyword-fallback only) ---
PATH_NO_TOOLS=$(build_pathdir_excluding "jq python3")
out3=$(run_dream_config_show "$PATH_NO_TOOLS" "no-tools")
if grep -q "N8N_USER=\\*\\*\\*" <<<"$out3" && ! grep -qF "$SECRET_USER" <<<"$out3"; then
pass "without jq+python3: N8N_USER masked via *user* keyword"
else
fail "without jq+python3: N8N_USER LEAKED — keyword fallback gap"
echo " --- output ---"; awk '{print " " $0}' <<<"$out3"
fi
if grep -q "LANGFUSE_INIT_USER_EMAIL=\\*\\*\\*" <<<"$out3" && ! grep -qF "$SECRET_EMAIL" <<<"$out3"; then
pass "without jq+python3: LANGFUSE_INIT_USER_EMAIL masked via *user*/*email* keyword"
else
fail "without jq+python3: LANGFUSE_INIT_USER_EMAIL LEAKED — keyword fallback gap"
echo " --- output ---"; awk '{print " " $0}' <<<"$out3"
fi

# --- Sanity: non-secret keys are NOT masked (no over-mask regression) ---
if grep -q "DREAM_VERSION=2.0.0-test" <<<"$out1"; then
pass "non-secret DREAM_VERSION shown in clear (no over-mask)"
else
fail "non-secret DREAM_VERSION incorrectly masked or missing"
fi

echo ""
echo "Result: $PASSED passed, $FAILED failed"
[[ $FAILED -eq 0 ]]
Loading
Loading