Skip to content

Commit ebcbf49

Browse files
committed
security: harden shell JSON parsing with shared helper
1 parent 94d03e1 commit ebcbf49

8 files changed

Lines changed: 267 additions & 22 deletions

File tree

bin/baudbot

Lines changed: 21 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ if [ -z "${BAUDBOT_ROOT:-}" ]; then
2121
BAUDBOT_ROOT="$(cd "$(dirname "$SCRIPT")/.." && pwd)"
2222
fi
2323

24+
# shellcheck source=bin/lib/json-common.sh
25+
source "$BAUDBOT_ROOT/bin/lib/json-common.sh"
26+
2427
# Colors (disabled if not a terminal)
2528
if [ -t 1 ]; then
2629
BOLD='\033[1m'
@@ -34,11 +37,15 @@ else
3437
fi
3538

3639
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"
40+
local package_json="$BAUDBOT_ROOT/package.json"
41+
local pkg_version=""
42+
43+
if [ -f "$package_json" ]; then
44+
pkg_version="$(json_get_string_or_empty "$package_json" "version")"
45+
[ -n "$pkg_version" ] && echo "$pkg_version" && return 0
4146
fi
47+
48+
echo "unknown"
4249
}
4350

4451
version_sha() {
@@ -73,7 +80,7 @@ version_sha() {
7380
# Runtime metadata fallback
7481
version_file="/home/${BAUDBOT_AGENT_USER:-baudbot_agent}/.pi/agent/baudbot-version.json"
7582
if [ -r "$version_file" ]; then
76-
sha="$(grep -E '"sha"[[:space:]]*:' "$version_file" | head -1 | sed 's/.*"sha"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/')"
83+
sha="$(json_get_string_or_empty "$version_file" "sha")"
7784
[ -n "$sha" ] && echo "${sha:0:7}" && return 0
7885
fi
7986

@@ -282,20 +289,25 @@ has_systemd() {
282289
print_deployed_version() {
283290
local agent_user="${BAUDBOT_AGENT_USER:-baudbot_agent}"
284291
local version_file="/home/$agent_user/.pi/agent/baudbot-version.json"
285-
local version_json=""
286292
local short=""
287293
local sha=""
288294
local branch=""
289295
local deployed_at=""
290296
local line=""
291297

292298
if [ -r "$version_file" ]; then
293-
version_json="$(cat "$version_file" 2>/dev/null || true)"
299+
short="$(json_get_string_or_empty "$version_file" "short")"
300+
sha="$(json_get_string_or_empty "$version_file" "sha")"
301+
branch="$(json_get_string_or_empty "$version_file" "branch")"
302+
deployed_at="$(json_get_string_or_empty "$version_file" "deployed_at")"
294303
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)"
304+
short="$(sudo -u "$agent_user" sh -c "cat '$version_file' 2>/dev/null" | json_get_string_stdin "short" 2>/dev/null || true)"
305+
sha="$(sudo -u "$agent_user" sh -c "cat '$version_file' 2>/dev/null" | json_get_string_stdin "sha" 2>/dev/null || true)"
306+
branch="$(sudo -u "$agent_user" sh -c "cat '$version_file' 2>/dev/null" | json_get_string_stdin "branch" 2>/dev/null || true)"
307+
deployed_at="$(sudo -u "$agent_user" sh -c "cat '$version_file' 2>/dev/null" | json_get_string_stdin "deployed_at" 2>/dev/null || true)"
296308
fi
297309

298-
if [ -z "$version_json" ]; then
310+
if [ -z "$short" ] && [ -z "$sha" ] && [ -z "$branch" ] && [ -z "$deployed_at" ]; then
299311
local release_target=""
300312
local release_sha=""
301313

@@ -309,11 +321,6 @@ print_deployed_version() {
309321
return 0
310322
fi
311323

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-
317324
if [ -z "$short" ] && [ -n "$sha" ]; then
318325
short="${sha:0:7}"
319326
fi

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/lib/json-common.sh

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
#!/bin/bash
2+
# Shared JSON parsing helpers for shell scripts.
3+
#
4+
# Return codes:
5+
# 0 => key found and value printed
6+
# 1 => JSON parsed, but key missing/non-string
7+
# 2 => JSON/file/tool error
8+
9+
_json_filter='if (type == "object") and has($k) and (.[$k] | type == "string") then .[$k] else empty end'
10+
11+
json_get_string() {
12+
local file="$1"
13+
local key="$2"
14+
15+
[ -n "$file" ] || return 2
16+
[ -n "$key" ] || return 2
17+
[ -r "$file" ] || return 2
18+
19+
if command -v jq >/dev/null 2>&1; then
20+
jq -er --arg k "$key" "$_json_filter" "$file" 2>/dev/null
21+
case "$?" in
22+
0) return 0 ;;
23+
1) return 1 ;;
24+
*) return 2 ;;
25+
esac
26+
fi
27+
28+
if ! command -v python3 >/dev/null 2>&1; then
29+
return 2
30+
fi
31+
32+
python3 - "$file" "$key" <<'PY'
33+
import json
34+
import sys
35+
36+
if len(sys.argv) != 3:
37+
sys.exit(2)
38+
39+
path = sys.argv[1]
40+
key = sys.argv[2]
41+
42+
try:
43+
with open(path, "r", encoding="utf-8") as f:
44+
data = json.load(f)
45+
except Exception:
46+
sys.exit(2)
47+
48+
if not isinstance(data, dict):
49+
sys.exit(1)
50+
51+
value = data.get(key)
52+
if not isinstance(value, str):
53+
sys.exit(1)
54+
55+
sys.stdout.write(value)
56+
PY
57+
}
58+
59+
json_get_string_stdin() {
60+
local key="$1"
61+
62+
[ -n "$key" ] || return 2
63+
64+
if command -v jq >/dev/null 2>&1; then
65+
jq -er --arg k "$key" "$_json_filter" 2>/dev/null
66+
case "$?" in
67+
0) return 0 ;;
68+
1) return 1 ;;
69+
*) return 2 ;;
70+
esac
71+
fi
72+
73+
if ! command -v python3 >/dev/null 2>&1; then
74+
return 2
75+
fi
76+
77+
python3 - "$key" <<'PY'
78+
import json
79+
import sys
80+
81+
if len(sys.argv) != 2:
82+
sys.exit(2)
83+
84+
key = sys.argv[1]
85+
86+
try:
87+
data = json.load(sys.stdin)
88+
except Exception:
89+
sys.exit(2)
90+
91+
if not isinstance(data, dict):
92+
sys.exit(1)
93+
94+
value = data.get(key)
95+
if not isinstance(value, str):
96+
sys.exit(1)
97+
98+
sys.stdout.write(value)
99+
PY
100+
}
101+
102+
json_get_string_or_empty() {
103+
local file="$1"
104+
local key="$2"
105+
106+
json_get_string "$file" "$key" 2>/dev/null || true
107+
}

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"

test/shell-scripts.test.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,8 @@ describe("shell script test suites", () => {
3131
expect(() => runScript("bin/env.test.sh")).not.toThrow();
3232
});
3333

34+
it("json helper", () => {
35+
expect(() => runScript("bin/lib/json-common.test.sh")).not.toThrow();
36+
});
37+
3438
});

0 commit comments

Comments
 (0)