Skip to content

Commit 2b49c8f

Browse files
yasinBursaliclaude
andcommitted
fix(dream-cli): close jq-less Git Bash leak for N8N_USER / LANGFUSE_INIT_USER_EMAIL
Maintainer audit on PR #994 (Lightheartdevs, 2026-04-28) flagged that schema-driven secret detection in `_cmd_config_load_secret_schema` ONLY fires when `jq` is on PATH. In Git Bash on Windows, `jq` is not installed by default, so the helper silently leaves `_cmd_config_schema_loaded=0` and `dream config show` falls through to the keyword regex `*secret*|*password*|*pass*|*token*|*key*|*salt*|*bearer*` — which doesn't cover the user/email-suffixed names recently flagged as secret in `.env.schema.json`. `N8N_USER` and `LANGFUSE_INIT_USER_EMAIL` print in clear. Two-layer fix: 1. Add a Python3 fallback to `_cmd_config_load_secret_schema`. If `jq` is absent, parse `.env.schema.json` via a heredoc'd `python3` and extract `secret == True` keys. The installer guarantees Python 3, and Git Bash on Windows usually has it via `winget install Python`. Schema-driven masking now works without jq. 2. Extend the keyword fallback regex to include `*user*|*email*` so the truly-no-jq-no-python case still masks the new schema secrets. Light over-mask risk on operational keys like USER_HOME but `cat .env` remains the unmasked escape hatch — `dream config show` defaults to over-mask on purpose. New regression `tests/test-dream-config-secret-mask.sh` (7 cases, wired into `make test`) exercises all three PATH conditions: - jq+python3 both present → schema-driven jq path - jq absent, python3 present → python fallback - neither present → keyword regex fallback Each case asserts `N8N_USER=***` and `LANGFUSE_INIT_USER_EMAIL=***` appear in stdout AND that the actual sensitive values do NOT leak. A sanity case verifies non-secret `DREAM_VERSION` is shown in clear. The PATH conditions are simulated by symlinking selected binaries into a tempdir, then running dream-cli with `PATH="$tempdir"`. shellcheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9cee345 commit 2b49c8f

3 files changed

Lines changed: 214 additions & 3 deletions

File tree

dream-server/Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ test: ## Run unit and contract tests
3333
@echo "=== Overlay/plist contracts ==="
3434
@bash tests/contracts/test-overlay-map-coherence.sh
3535
@bash tests/contracts/test-plist-log-paths.sh
36+
@echo ""
37+
@echo "=== dream config show secret-mask matrix ==="
38+
@bash tests/test-dream-config-secret-mask.sh
3639

3740
bats: ## Run BATS unit tests for shell libraries
3841
@echo "=== BATS unit tests ==="

dream-server/dream-cli

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,10 +1188,39 @@ _cmd_config_load_secret_schema() {
11881188
local _schema_path="$INSTALL_DIR/.env.schema.json"
11891189
_cmd_config_secret_keys=()
11901190
_cmd_config_schema_loaded=0
1191-
if [[ -f "$_schema_path" ]] && command -v jq >/dev/null 2>&1; then
1191+
[[ -f "$_schema_path" ]] || return 0
1192+
1193+
# Prefer jq when available — fast, single-process, schema-typed.
1194+
if command -v jq >/dev/null 2>&1; then
11921195
mapfile -t _cmd_config_secret_keys < <(jq -r '.properties | to_entries[] | select(.value.secret == true) | .key' "$_schema_path" 2>/dev/null)
11931196
_cmd_config_schema_loaded=1
1197+
return 0
11941198
fi
1199+
1200+
# python3 fallback for environments without jq (Git Bash on Windows
1201+
# is the common one). The installer guarantees python3, so this
1202+
# path closes the gap where N8N_USER / LANGFUSE_INIT_USER_EMAIL etc.
1203+
# would otherwise leak through the keyword fallback.
1204+
if command -v python3 >/dev/null 2>&1; then
1205+
mapfile -t _cmd_config_secret_keys < <(python3 - "$_schema_path" <<'PYEOF' 2>/dev/null
1206+
import json, sys
1207+
try:
1208+
with open(sys.argv[1]) as f:
1209+
data = json.load(f)
1210+
except (OSError, json.JSONDecodeError):
1211+
sys.exit(0)
1212+
for key, spec in (data.get("properties") or {}).items():
1213+
if isinstance(spec, dict) and spec.get("secret") is True:
1214+
print(key)
1215+
PYEOF
1216+
)
1217+
_cmd_config_schema_loaded=1
1218+
return 0
1219+
fi
1220+
1221+
# Neither jq nor python3 — leave _cmd_config_schema_loaded=0 so
1222+
# _cmd_config_is_secret falls through to the keyword regex (which
1223+
# covers user/email keys explicitly to avoid leaks in this case).
11951224
}
11961225

11971226
_cmd_config_is_secret() {
@@ -1201,11 +1230,17 @@ _cmd_config_is_secret() {
12011230
[[ "$_k" == "$_s" ]] && return 0
12021231
done
12031232
# Fall through to keyword match — defense in depth against schema gaps
1204-
# and against malformed schemas where jq returns zero secret keys.
1233+
# and against malformed schemas where jq/python returns zero secret keys.
12051234
fi
12061235
_kl="${_k,,}"
1236+
# `*user*` and `*email*` cover N8N_USER, LANGFUSE_INIT_USER_EMAIL, etc.
1237+
# in environments where neither jq nor python3 was available to read
1238+
# `.env.schema.json` (Git Bash without jq). Risks light over-masking
1239+
# on operational keys like USER_HOME, but `cat .env` remains the
1240+
# escape hatch — `dream config show` defaults to over-mask on
1241+
# purpose.
12071242
case "$_kl" in
1208-
*secret*|*password*|*pass*|*token*|*key*|*salt*|*bearer*) return 0 ;;
1243+
*secret*|*password*|*pass*|*token*|*key*|*salt*|*bearer*|*user*|*email*) return 0 ;;
12091244
esac
12101245
return 1
12111246
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
#!/usr/bin/env bash
2+
# ============================================================================
3+
# Regression: `dream config show` masks `N8N_USER` and
4+
# `LANGFUSE_INIT_USER_EMAIL` even in environments without `jq`.
5+
# ============================================================================
6+
# Audit follow-up on PR #994 (Lightheartdevs, 2026-04-28):
7+
#
8+
# "Schema-driven secret masking is useful, but the CLI only learns
9+
# the schema secret flags through `jq`. In Git Bash without `jq`,
10+
# newly marked user/email fields such as `N8N_USER` and
11+
# `LANGFUSE_INIT_USER_EMAIL` can still print in clear. Please either
12+
# make schema parsing available without `jq` for this command or
13+
# extend the fallback mask to cover the new schema secrets."
14+
#
15+
# Both fixes are now in place: a Python fallback parser when `jq` is
16+
# absent, plus `*user*` / `*email*` keyword fallback when neither is
17+
# present. This test exercises all three PATH configurations.
18+
# ============================================================================
19+
20+
set -euo pipefail
21+
22+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
23+
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
24+
DREAM_CLI="$ROOT_DIR/dream-cli"
25+
26+
GREEN='\033[0;32m'
27+
RED='\033[0;31m'
28+
NC='\033[0m'
29+
30+
PASSED=0
31+
FAILED=0
32+
33+
pass() { echo -e " ${GREEN}✓ PASS${NC} $1"; PASSED=$((PASSED + 1)); }
34+
fail() { echo -e " ${RED}✗ FAIL${NC} $1"; FAILED=$((FAILED + 1)); }
35+
36+
echo ""
37+
echo "╔═══════════════════════════════════════════════╗"
38+
echo "║ dream config show — secret masking matrix ║"
39+
echo "╚═══════════════════════════════════════════════╝"
40+
echo ""
41+
42+
if [[ ! -x "$DREAM_CLI" ]]; then
43+
fail "dream-cli not found at $DREAM_CLI"
44+
echo ""; echo "Result: $PASSED passed, $FAILED failed"; exit 1
45+
fi
46+
47+
# Scaffold a hermetic install dir. The schema marks N8N_USER and
48+
# LANGFUSE_INIT_USER_EMAIL as secret:true; .env contains values that
49+
# must NEVER appear in `dream config show` output.
50+
TEMP_DIR=$(mktemp -d)
51+
trap 'rm -rf "$TEMP_DIR"' EXIT
52+
53+
INSTALL_SCAFFOLD="$TEMP_DIR/install"
54+
mkdir -p "$INSTALL_SCAFFOLD"
55+
# check_install requires either docker-compose.base.yml or docker-compose.yml
56+
touch "$INSTALL_SCAFFOLD/docker-compose.base.yml"
57+
58+
cat > "$INSTALL_SCAFFOLD/.env" <<'EOF'
59+
# Test fixture
60+
N8N_USER=actual-admin-username
61+
LANGFUSE_INIT_USER_EMAIL=admin@example.test
62+
DREAM_VERSION=2.0.0-test
63+
HOST_RAM_GB=32
64+
EOF
65+
66+
cat > "$INSTALL_SCAFFOLD/.env.schema.json" <<'EOF'
67+
{
68+
"properties": {
69+
"N8N_USER": {"type": "string", "secret": true},
70+
"LANGFUSE_INIT_USER_EMAIL": {"type": "string", "secret": true},
71+
"DREAM_VERSION": {"type": "string"},
72+
"HOST_RAM_GB": {"type": "string"}
73+
}
74+
}
75+
EOF
76+
77+
# Sentinel values whose appearance in stdout would prove a leak.
78+
SECRET_USER='actual-admin-username'
79+
SECRET_EMAIL='admin@example.test'
80+
81+
run_dream_config_show() {
82+
# Invokes dream-cli with a controlled PATH to simulate environments
83+
# with/without jq + python3. NO_COLOR=1 keeps output ASCII.
84+
local _path="$1"
85+
local _label="$2"
86+
local _output
87+
_output=$(NO_COLOR=1 PATH="$_path" DREAM_HOME="$INSTALL_SCAFFOLD" \
88+
"$BASH" "$DREAM_CLI" config show 2>&1)
89+
echo "$_output"
90+
}
91+
92+
# Discover real paths to bash, sed, awk, mktemp, etc. so the CLI runs.
93+
# We strip jq and/or python3 from PATH by listing only their needed
94+
# siblings. The simplest approach: build a path that excludes a
95+
# specific binary by symlinking required binaries into a tempdir.
96+
build_pathdir_excluding() {
97+
# build_pathdir_excluding "<exclude1> <exclude2> ..."
98+
local _excludes="$1"
99+
local _pdir="$TEMP_DIR/pathdir-$RANDOM"
100+
mkdir -p "$_pdir"
101+
local _bin
102+
# Tools dream-cli (the section we exercise) actually uses.
103+
for _bin in bash sh ls cat grep sed awk tr cut sort head tail \
104+
printf echo mkdir rm tee dirname basename pwd command \
105+
python3 jq find env; do
106+
local _real
107+
_real="$(command -v "$_bin" 2>/dev/null || true)"
108+
[[ -z "$_real" ]] && continue
109+
# Skip excluded names.
110+
case " $_excludes " in *" $_bin "*) continue ;; esac
111+
ln -s "$_real" "$_pdir/$_bin"
112+
done
113+
echo "$_pdir"
114+
}
115+
116+
# --- Case 1: jq + python3 both present (schema-driven path) ---
117+
PATH_FULL=$(build_pathdir_excluding "")
118+
out1=$(run_dream_config_show "$PATH_FULL" "full")
119+
if grep -q "N8N_USER=\\*\\*\\*" <<<"$out1" && ! grep -qF "$SECRET_USER" <<<"$out1"; then
120+
pass "with jq+python3: N8N_USER masked, value not leaked"
121+
else
122+
fail "with jq+python3: N8N_USER not masked correctly"
123+
echo " --- output ---"; awk '{print " " $0}' <<<"$out1"
124+
fi
125+
if grep -q "LANGFUSE_INIT_USER_EMAIL=\\*\\*\\*" <<<"$out1" && ! grep -qF "$SECRET_EMAIL" <<<"$out1"; then
126+
pass "with jq+python3: LANGFUSE_INIT_USER_EMAIL masked, value not leaked"
127+
else
128+
fail "with jq+python3: LANGFUSE_INIT_USER_EMAIL not masked correctly"
129+
echo " --- output ---"; awk '{print " " $0}' <<<"$out1"
130+
fi
131+
132+
# --- Case 2: no jq, python3 present (Git-Bash-without-jq simulation) ---
133+
PATH_NO_JQ=$(build_pathdir_excluding "jq")
134+
out2=$(run_dream_config_show "$PATH_NO_JQ" "no-jq")
135+
if grep -q "N8N_USER=\\*\\*\\*" <<<"$out2" && ! grep -qF "$SECRET_USER" <<<"$out2"; then
136+
pass "without jq: N8N_USER masked via python3 fallback"
137+
else
138+
fail "without jq: N8N_USER LEAKED — Git Bash regression"
139+
echo " --- output ---"; awk '{print " " $0}' <<<"$out2"
140+
fi
141+
if grep -q "LANGFUSE_INIT_USER_EMAIL=\\*\\*\\*" <<<"$out2" && ! grep -qF "$SECRET_EMAIL" <<<"$out2"; then
142+
pass "without jq: LANGFUSE_INIT_USER_EMAIL masked via python3 fallback"
143+
else
144+
fail "without jq: LANGFUSE_INIT_USER_EMAIL LEAKED — Git Bash regression"
145+
echo " --- output ---"; awk '{print " " $0}' <<<"$out2"
146+
fi
147+
148+
# --- Case 3: neither jq nor python3 (keyword-fallback only) ---
149+
PATH_NO_TOOLS=$(build_pathdir_excluding "jq python3")
150+
out3=$(run_dream_config_show "$PATH_NO_TOOLS" "no-tools")
151+
if grep -q "N8N_USER=\\*\\*\\*" <<<"$out3" && ! grep -qF "$SECRET_USER" <<<"$out3"; then
152+
pass "without jq+python3: N8N_USER masked via *user* keyword"
153+
else
154+
fail "without jq+python3: N8N_USER LEAKED — keyword fallback gap"
155+
echo " --- output ---"; awk '{print " " $0}' <<<"$out3"
156+
fi
157+
if grep -q "LANGFUSE_INIT_USER_EMAIL=\\*\\*\\*" <<<"$out3" && ! grep -qF "$SECRET_EMAIL" <<<"$out3"; then
158+
pass "without jq+python3: LANGFUSE_INIT_USER_EMAIL masked via *user*/*email* keyword"
159+
else
160+
fail "without jq+python3: LANGFUSE_INIT_USER_EMAIL LEAKED — keyword fallback gap"
161+
echo " --- output ---"; awk '{print " " $0}' <<<"$out3"
162+
fi
163+
164+
# --- Sanity: non-secret keys are NOT masked (no over-mask regression) ---
165+
if grep -q "DREAM_VERSION=2.0.0-test" <<<"$out1"; then
166+
pass "non-secret DREAM_VERSION shown in clear (no over-mask)"
167+
else
168+
fail "non-secret DREAM_VERSION incorrectly masked or missing"
169+
fi
170+
171+
echo ""
172+
echo "Result: $PASSED passed, $FAILED failed"
173+
[[ $FAILED -eq 0 ]]

0 commit comments

Comments
 (0)