Skip to content

Commit 47cecc9

Browse files
authored
security: harden JSON parsing in shell scripts (#104)
1 parent eb80ed4 commit 47cecc9

12 files changed

Lines changed: 250 additions & 28 deletions

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ Baudbot is designed as shared engineering infrastructure, not a single-user desk
5252
| **CPU** | 2 vCPU | 4 vCPU |
5353
| **Disk** | 20 GB | 40 GB+ (repos, dependencies, Docker images) |
5454

55+
System package dependencies (installed by `baudbot install`): `git`, `curl`, `tmux`, `iptables`, `docker`, `gh`, `jq`, `sudo`.
56+
5557
## Quick Start
5658

5759
```bash

bin/baudbot

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,31 @@ if [ -z "${BAUDBOT_ROOT:-}" ]; then
1818
# Handle relative symlinks
1919
[[ "$SCRIPT" != /* ]] && SCRIPT="$DIR/$SCRIPT"
2020
done
21-
BAUDBOT_ROOT="$(cd "$(dirname "$SCRIPT")/.." && pwd)"
21+
22+
CANDIDATE_ROOT="$(cd "$(dirname "$SCRIPT")/.." && pwd)"
23+
if [ -f "$CANDIDATE_ROOT/bin/baudbot" ] && [ -f "$CANDIDATE_ROOT/package.json" ]; then
24+
BAUDBOT_ROOT="$CANDIDATE_ROOT"
25+
elif [ -d /opt/baudbot/current/bin ]; then
26+
BAUDBOT_ROOT="$(readlink -f /opt/baudbot/current 2>/dev/null || echo /opt/baudbot/current)"
27+
else
28+
BAUDBOT_ROOT="$CANDIDATE_ROOT"
29+
fi
2230
fi
2331

32+
json_get_string_or_empty() {
33+
local file="$1"
34+
local key="$2"
35+
[ -r "$file" ] || return 0
36+
command -v jq >/dev/null 2>&1 || return 0
37+
jq -er --arg k "$key" 'if (type == "object") and has($k) and (.[$k] | type == "string") then .[$k] else empty end' "$file" 2>/dev/null || true
38+
}
39+
40+
json_get_string_stdin_or_empty() {
41+
local key="$1"
42+
command -v jq >/dev/null 2>&1 || return 0
43+
jq -er --arg k "$key" 'if (type == "object") and has($k) and (.[$k] | type == "string") then .[$k] else empty end' 2>/dev/null || true
44+
}
45+
2446
# Colors (disabled if not a terminal)
2547
if [ -t 1 ]; then
2648
BOLD='\033[1m'
@@ -34,11 +56,15 @@ else
3456
fi
3557

3658
version() {
37-
if [ -f "$BAUDBOT_ROOT/package.json" ]; then
38-
grep '"version"' "$BAUDBOT_ROOT/package.json" | head -1 | sed 's/.*: *"\(.*\)".*/\1/'
39-
else
40-
echo "unknown"
59+
local package_json="$BAUDBOT_ROOT/package.json"
60+
local pkg_version=""
61+
62+
if [ -f "$package_json" ]; then
63+
pkg_version="$(json_get_string_or_empty "$package_json" "version")"
64+
[ -n "$pkg_version" ] && echo "$pkg_version" && return 0
4165
fi
66+
67+
echo "unknown"
4268
}
4369

4470
version_sha() {
@@ -73,7 +99,7 @@ version_sha() {
7399
# Runtime metadata fallback
74100
version_file="/home/${BAUDBOT_AGENT_USER:-baudbot_agent}/.pi/agent/baudbot-version.json"
75101
if [ -r "$version_file" ]; then
76-
sha="$(grep -E '"sha"[[:space:]]*:' "$version_file" | head -1 | sed 's/.*"sha"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')"
102+
sha="$(json_get_string_or_empty "$version_file" "sha")"
77103
[ -n "$sha" ] && echo "${sha:0:7}" && return 0
78104
fi
79105

@@ -282,20 +308,29 @@ has_systemd() {
282308
print_deployed_version() {
283309
local agent_user="${BAUDBOT_AGENT_USER:-baudbot_agent}"
284310
local version_file="/home/$agent_user/.pi/agent/baudbot-version.json"
285-
local version_json=""
286311
local short=""
287312
local sha=""
288313
local branch=""
289314
local deployed_at=""
290315
local line=""
291316

292317
if [ -r "$version_file" ]; then
293-
version_json="$(cat "$version_file" 2>/dev/null || true)"
318+
short="$(json_get_string_or_empty "$version_file" "short")"
319+
sha="$(json_get_string_or_empty "$version_file" "sha")"
320+
branch="$(json_get_string_or_empty "$version_file" "branch")"
321+
deployed_at="$(json_get_string_or_empty "$version_file" "deployed_at")"
294322
elif [ "$(id -u)" -eq 0 ] && id "$agent_user" >/dev/null 2>&1; then
295-
version_json="$(sudo -u "$agent_user" cat "$version_file" 2>/dev/null || true)"
323+
local version_json=""
324+
version_json="$(sudo -u "$agent_user" sh -c "cat '$version_file' 2>/dev/null" || true)"
325+
if [ -n "$version_json" ]; then
326+
short="$(printf '%s' "$version_json" | json_get_string_stdin_or_empty "short" 2>/dev/null || true)"
327+
sha="$(printf '%s' "$version_json" | json_get_string_stdin_or_empty "sha" 2>/dev/null || true)"
328+
branch="$(printf '%s' "$version_json" | json_get_string_stdin_or_empty "branch" 2>/dev/null || true)"
329+
deployed_at="$(printf '%s' "$version_json" | json_get_string_stdin_or_empty "deployed_at" 2>/dev/null || true)"
330+
fi
296331
fi
297332

298-
if [ -z "$version_json" ]; then
333+
if [ -z "$short" ] && [ -z "$sha" ] && [ -z "$branch" ] && [ -z "$deployed_at" ]; then
299334
local release_target=""
300335
local release_sha=""
301336

@@ -309,11 +344,6 @@ print_deployed_version() {
309344
return 0
310345
fi
311346

312-
short="$(printf '%s\n' "$version_json" | grep -E '"short"[[:space:]]*:' | head -1 | sed 's/.*"short"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')"
313-
sha="$(printf '%s\n' "$version_json" | grep -E '"sha"[[:space:]]*:' | head -1 | sed 's/.*"sha"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')"
314-
branch="$(printf '%s\n' "$version_json" | grep -E '"branch"[[:space:]]*:' | head -1 | sed 's/.*"branch"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')"
315-
deployed_at="$(printf '%s\n' "$version_json" | grep -E '"deployed_at"[[:space:]]*:' | head -1 | sed 's/.*"deployed_at"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')"
316-
317347
if [ -z "$short" ] && [ -n "$sha" ]; then
318348
short="${sha:0:7}"
319349
fi

bin/ci/setup-arch.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77

88
set -euo pipefail
99

10-
echo "=== [Arch] Installing git (needed to init test repo) ==="
11-
pacman -Sy --noconfirm --needed git sudo 2>&1 | tail -3
10+
echo "=== [Arch] Installing base CI deps ==="
11+
pacman -Sy --noconfirm --needed git jq sudo 2>&1 | tail -3
1212

1313
echo "=== Preparing source ==="
1414
useradd -m -s /bin/bash baudbot_admin

bin/deploy.sh

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ BAUDBOT_SRC="${BAUDBOT_SRC:-$(cd "$(dirname "$0")/.." && pwd)}"
1818
BAUDBOT_HOME="${BAUDBOT_HOME:-/home/baudbot_agent}"
1919
AGENT_USER="baudbot_agent"
2020
DRY_RUN=0
21+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
22+
23+
# shellcheck source=bin/lib/json-common.sh
24+
source "$SCRIPT_DIR/lib/json-common.sh"
2125

2226
# Helper: run a command as baudbot_agent
2327
as_agent() {
@@ -385,9 +389,9 @@ if [ "$DRY_RUN" -eq 0 ]; then
385389
GIT_SHA_SHORT=$(cd "$BAUDBOT_SRC" && git rev-parse --short HEAD 2>/dev/null || echo "unknown")
386390
GIT_BRANCH=$(cd "$BAUDBOT_SRC" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
387391
elif [ -f "$RELEASE_META_FILE" ]; then
388-
GIT_SHA=$(grep '"sha"' "$RELEASE_META_FILE" | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/' || true)
389-
GIT_SHA_SHORT=$(grep '"short"' "$RELEASE_META_FILE" | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/' || true)
390-
GIT_BRANCH=$(grep '"branch"' "$RELEASE_META_FILE" | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/' || true)
392+
GIT_SHA="$(json_get_string_or_empty "$RELEASE_META_FILE" "sha")"
393+
GIT_SHA_SHORT="$(json_get_string_or_empty "$RELEASE_META_FILE" "short")"
394+
GIT_BRANCH="$(json_get_string_or_empty "$RELEASE_META_FILE" "branch")"
391395
fi
392396

393397
[ -n "$GIT_SHA" ] || GIT_SHA="unknown"

bin/doctor.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ else
7171
fail "varlock not found"
7272
fi
7373

74+
if command -v jq &>/dev/null; then
75+
pass "jq is installed"
76+
else
77+
fail "jq not found (required for shell JSON parsing)"
78+
fi
79+
7480
if command -v docker &>/dev/null; then
7581
pass "docker is available"
7682
else

bin/lib/json-common.sh

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
#!/bin/bash
2+
# Shared JSON parsing helpers for shell scripts.
3+
#
4+
# jq is a required runtime dependency.
5+
#
6+
# Return codes:
7+
# 0 => key found and value printed
8+
# 1 => JSON parsed, but key missing/non-string
9+
# 2 => JSON/file/tool error (including missing jq)
10+
11+
_json_filter='if (type == "object") and has($k) and (.[$k] | type == "string") then .[$k] else empty end'
12+
13+
json_require_jq() {
14+
command -v jq >/dev/null 2>&1
15+
}
16+
17+
json_get_string() {
18+
local file="$1"
19+
local key="$2"
20+
21+
[ -n "$file" ] || return 2
22+
[ -n "$key" ] || return 2
23+
[ -r "$file" ] || return 2
24+
json_require_jq || return 2
25+
26+
jq -er --arg k "$key" "$_json_filter" "$file" 2>/dev/null
27+
case "$?" in
28+
0) return 0 ;;
29+
1) return 1 ;;
30+
*) return 2 ;;
31+
esac
32+
}
33+
34+
json_get_string_stdin() {
35+
local key="$1"
36+
37+
[ -n "$key" ] || return 2
38+
json_require_jq || return 2
39+
40+
jq -er --arg k "$key" "$_json_filter" 2>/dev/null
41+
case "$?" in
42+
0) return 0 ;;
43+
1) return 1 ;;
44+
*) return 2 ;;
45+
esac
46+
}
47+
48+
json_get_string_or_empty() {
49+
local file="$1"
50+
local key="$2"
51+
52+
json_get_string "$file" "$key" 2>/dev/null || true
53+
}

bin/lib/json-common.test.sh

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
#!/bin/bash
2+
# Tests for bin/lib/json-common.sh
3+
4+
set -euo pipefail
5+
6+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
7+
# shellcheck source=bin/lib/json-common.sh
8+
source "$SCRIPT_DIR/json-common.sh"
9+
10+
TOTAL=0
11+
PASSED=0
12+
FAILED=0
13+
14+
run_test() {
15+
local name="$1"
16+
shift
17+
local out
18+
19+
TOTAL=$((TOTAL + 1))
20+
printf " %-45s " "$name"
21+
22+
out="$(mktemp /tmp/baudbot-json-common-test-output.XXXXXX)"
23+
if "$@" >"$out" 2>&1; then
24+
echo ""
25+
PASSED=$((PASSED + 1))
26+
else
27+
echo "✗ FAILED"
28+
tail -40 "$out" | sed 's/^/ /'
29+
FAILED=$((FAILED + 1))
30+
fi
31+
rm -f "$out"
32+
}
33+
34+
test_parses_string_key_with_whitespace_variations() {
35+
(
36+
set -euo pipefail
37+
local tmp file value
38+
tmp="$(mktemp -d /tmp/baudbot-json-common-test.XXXXXX)"
39+
trap 'rm -rf "$tmp"' EXIT
40+
41+
file="$tmp/meta.json"
42+
cat > "$file" <<'EOF'
43+
{
44+
"sha" : "abc123",
45+
"short": "abc123",
46+
"nested": { "x": 1 }
47+
}
48+
EOF
49+
50+
value="$(json_get_string "$file" "sha")"
51+
[ "$value" = "abc123" ]
52+
)
53+
}
54+
55+
test_missing_key_returns_nonzero() {
56+
(
57+
set -euo pipefail
58+
local tmp file
59+
tmp="$(mktemp -d /tmp/baudbot-json-common-test.XXXXXX)"
60+
trap 'rm -rf "$tmp"' EXIT
61+
62+
file="$tmp/meta.json"
63+
printf '{"sha":"abc123"}\n' > "$file"
64+
65+
if json_get_string "$file" "branch" >/dev/null 2>&1; then
66+
return 1
67+
fi
68+
69+
[ -z "$(json_get_string_or_empty "$file" "branch")" ]
70+
)
71+
}
72+
73+
test_malformed_json_returns_nonzero() {
74+
(
75+
set -euo pipefail
76+
local tmp file
77+
tmp="$(mktemp -d /tmp/baudbot-json-common-test.XXXXXX)"
78+
trap 'rm -rf "$tmp"' EXIT
79+
80+
file="$tmp/bad.json"
81+
printf '{"sha": "abc123"\n' > "$file"
82+
83+
if json_get_string "$file" "sha" >/dev/null 2>&1; then
84+
return 1
85+
fi
86+
)
87+
}
88+
89+
test_stdin_parser_handles_whitespace() {
90+
(
91+
set -euo pipefail
92+
local value
93+
94+
value="$(printf '{\n "deployed_at" : "2026-02-21T20:00:00Z"\n}\n' | json_get_string_stdin "deployed_at")"
95+
[ "$value" = "2026-02-21T20:00:00Z" ]
96+
)
97+
}
98+
99+
echo "=== json-common tests ==="
100+
echo ""
101+
102+
run_test "parses string key with whitespace" test_parses_string_key_with_whitespace_variations
103+
run_test "missing key returns nonzero" test_missing_key_returns_nonzero
104+
run_test "malformed json returns nonzero" test_malformed_json_returns_nonzero
105+
run_test "stdin parser handles whitespace" test_stdin_parser_handles_whitespace
106+
107+
echo ""
108+
echo "=== $PASSED/$TOTAL passed, $FAILED failed ==="
109+
110+
if [ "$FAILED" -gt 0 ]; then
111+
exit 1
112+
fi

bin/rollback-release.sh

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ EOF
4141

4242
# shellcheck source=bin/lib/release-common.sh
4343
source "$SCRIPT_DIR/lib/release-common.sh"
44+
# shellcheck source=bin/lib/json-common.sh
45+
source "$SCRIPT_DIR/lib/json-common.sh"
4446

4547
TARGET_SPEC="${1:-previous}"
4648
if [ "$#" -gt 0 ]; then
@@ -159,10 +161,10 @@ run_restart_and_health() {
159161
local expected_sha
160162
local deployed_sha
161163

162-
expected_sha=$(grep '"sha"' "$TARGET_RELEASE/baudbot-release.json" 2>/dev/null | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/' || true)
164+
expected_sha="$(json_get_string_or_empty "$TARGET_RELEASE/baudbot-release.json" "sha")"
163165
[ -n "$expected_sha" ] || expected_sha="$(basename "$TARGET_RELEASE")"
164166

165-
deployed_sha="$(sudo -u "$BAUDBOT_AGENT_USER" sh -c "grep '\"sha\"' '$version_file' 2>/dev/null | head -1 | sed 's/.*\"sha\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/'")"
167+
deployed_sha="$(sudo -u "$BAUDBOT_AGENT_USER" sh -c "cat '$version_file' 2>/dev/null" | json_get_string_stdin "sha" 2>/dev/null || true)"
166168

167169
if [ -z "$deployed_sha" ]; then
168170
die "deployed version file missing or unreadable: $version_file"

bin/security-audit.sh

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ set -euo pipefail
1414
BAUDBOT_HOME="${BAUDBOT_HOME:-/home/baudbot_agent}"
1515
# Source repo — auto-detect from this script's location, or use env override
1616
BAUDBOT_SRC="${BAUDBOT_SRC:-$(cd "$(dirname "$0")/.." && pwd)}"
17+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
18+
19+
# shellcheck source=bin/lib/json-common.sh
20+
source "$SCRIPT_DIR/lib/json-common.sh"
21+
1722
DEEP=0
1823
FIX=0
1924
for arg in "$@"; do
@@ -224,8 +229,10 @@ VERSION_FILE="$BAUDBOT_HOME/.pi/agent/baudbot-version.json"
224229
MANIFEST_FILE="$BAUDBOT_HOME/.pi/agent/baudbot-manifest.json"
225230

226231
if [ -f "$VERSION_FILE" ]; then
227-
deploy_sha=$(grep '"short"' "$VERSION_FILE" 2>/dev/null | sed 's/.*: *"\([^"]*\)".*/\1/' || echo "?")
228-
deploy_ts=$(grep '"deployed_at"' "$VERSION_FILE" 2>/dev/null | sed 's/.*: *"\([^"]*\)".*/\1/' || echo "?")
232+
deploy_sha="$(json_get_string_or_empty "$VERSION_FILE" "short")"
233+
deploy_ts="$(json_get_string_or_empty "$VERSION_FILE" "deployed_at")"
234+
[ -n "$deploy_sha" ] || deploy_sha="?"
235+
[ -n "$deploy_ts" ] || deploy_ts="?"
229236
ok "Deployed version: $deploy_sha ($deploy_ts)"
230237
else
231238
finding "WARN" "No version stamp found — run deploy.sh" ""

bin/update-release.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ die() {
5858

5959
# shellcheck source=bin/lib/release-common.sh
6060
source "$SCRIPT_DIR/lib/release-common.sh"
61+
# shellcheck source=bin/lib/json-common.sh
62+
source "$SCRIPT_DIR/lib/json-common.sh"
6163

6264
cleanup() {
6365
if [ -n "$CHECKOUT_DIR" ] && [ -d "$CHECKOUT_DIR" ]; then
@@ -305,7 +307,7 @@ run_restart_and_health() {
305307
local version_file="$BAUDBOT_AGENT_HOME/.pi/agent/baudbot-version.json"
306308
local deployed_sha
307309

308-
deployed_sha="$(sudo -u "$BAUDBOT_AGENT_USER" sh -c "grep '\"sha\"' '$version_file' 2>/dev/null | head -1 | sed 's/.*\"sha\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/'")"
310+
deployed_sha="$(sudo -u "$BAUDBOT_AGENT_USER" sh -c "cat '$version_file' 2>/dev/null" | json_get_string_stdin "sha" 2>/dev/null || true)"
309311

310312
if [ -z "$deployed_sha" ]; then
311313
die "deployed version file missing or unreadable: $version_file"

0 commit comments

Comments
 (0)