test: add bash test suite covering pipelock helpers and smoke flows
Adds tests/ with a tiny bash assert harness, manifest fixtures, and a
runner. No framework dependency — each test file is self-contained
and exits 0 on pass / 1 on fail; tests/run_tests.sh aggregates.
Unit tests (no docker):
- pipelock_naming: container_name, proxy_url, proxy_host_port shape
- pipelock_classify: _pipelock_is_ipv4_literal classifier coverage
- pipelock_allowlist: bottle_allowlist + ssh hostnames/ip_cidrs/
trusted_domains + effective_allowlist union/dedup/sort, plus
rejection of non-string entries
- pipelock_yaml: emitter shape (mode/enforce/api_allowlist/forward_proxy/
dlp), conditional ssrf+trusted_domains blocks, secret hygiene
(manifest env values must not appear in YAML), file mode 600
Integration tests (require docker, skip cleanly otherwise):
- pipelock_image: pinned digest's ENTRYPOINT is /pipelock and CMD
contains 'run' and the binary --version succeeds — would catch a
future image bump that changes the launcher's argv contract
- pipelock_sidecar_smoke: docker create + cp YAML to /etc/pipelock.yaml
+ start, then probe /health — the regression test for the bug
where the YAML was written to /etc/pipelock/ (parent dir absent in
the distroless image)
- dry_run_plan: cli.sh start --dry-run shows the egress line,
counts the bottle's entry into the effective allowlist, prints
the dry-run banner, and creates zero docker resources
- orphan_cleanup: the cleanup primitives the start-flow trap depends
on (network_remove, pipelock_stop) are idempotent against
missing/never-existed resources, so the trap is safe even if
pipelock_start dies before everything is wired up
Assisted-by: Claude Code
This commit is contained in:
Executable
+89
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env bash
|
||||
# Unit: allowlist resolution — pipelock_bottle_allowlist,
|
||||
# pipelock_bottle_ssh_hostnames, pipelock_bottle_ssh_ip_cidrs,
|
||||
# pipelock_bottle_ssh_trusted_domains, pipelock_effective_allowlist.
|
||||
TEST_NAME="pipelock_allowlist"
|
||||
|
||||
. "$(dirname "$0")/../lib/common.sh"
|
||||
# shellcheck source=../../lib/log.sh
|
||||
. "${REPO_ROOT}/lib/log.sh"
|
||||
# shellcheck source=../../lib/pipelock.sh
|
||||
. "${REPO_ROOT}/lib/pipelock.sh"
|
||||
|
||||
# --- bottle_allowlist (egress.allowlist parsing) ---
|
||||
|
||||
m="$(write_fixture fixture_with_egress)"
|
||||
out="$(pipelock_bottle_allowlist "$m" dev)"
|
||||
assert_contains "$out" "github.com" "bottle_allowlist: github.com present"
|
||||
assert_contains "$out" "gitlab.com" "bottle_allowlist: gitlab.com present"
|
||||
assert_contains "$out" "registry.npmjs.org" "bottle_allowlist: npmjs present"
|
||||
rm -f "$m"
|
||||
|
||||
m="$(write_fixture fixture_minimal)"
|
||||
out="$(pipelock_bottle_allowlist "$m" dev)"
|
||||
assert_eq "" "$out" "bottle_allowlist: empty when no egress block"
|
||||
rm -f "$m"
|
||||
|
||||
# --- ssh hostnames + classification ---
|
||||
|
||||
m="$(write_fixture fixture_with_ssh)"
|
||||
hosts="$(pipelock_bottle_ssh_hostnames "$m" dev)"
|
||||
assert_contains "$hosts" "100.78.141.42" "ssh_hostnames: ipv4 included"
|
||||
assert_contains "$hosts" "github.com" "ssh_hostnames: hostname included"
|
||||
|
||||
cidrs="$(pipelock_bottle_ssh_ip_cidrs "$m" dev)"
|
||||
assert_contains "$cidrs" "100.78.141.42/32" "ssh_ip_cidrs: ipv4 emitted as /32"
|
||||
assert_not_contains "$cidrs" "github.com" "ssh_ip_cidrs: hostname not in cidr list"
|
||||
|
||||
trusted="$(pipelock_bottle_ssh_trusted_domains "$m" dev)"
|
||||
assert_contains "$trusted" "github.com" "ssh_trusted_domains: hostname present"
|
||||
assert_not_contains "$trusted" "100.78.141.42" "ssh_trusted_domains: ipv4 not present"
|
||||
rm -f "$m"
|
||||
|
||||
# --- effective_allowlist union (defaults + bottle.allowlist + ssh.Hostname) ---
|
||||
|
||||
# Combine egress + ssh fixtures into one manifest.
|
||||
combined="$(mktemp)"
|
||||
cat > "$combined" <<'JSON'
|
||||
{
|
||||
"bottles": {
|
||||
"dev": {
|
||||
"egress": { "allowlist": ["registry.npmjs.org"] },
|
||||
"ssh": [
|
||||
{ "Host": "ts", "IdentityFile": "/dev/null", "Hostname": "100.78.141.42", "User": "git", "Port": 30009 },
|
||||
{ "Host": "gh", "IdentityFile": "/dev/null", "Hostname": "github.com", "User": "git", "Port": 22 }
|
||||
]
|
||||
}
|
||||
},
|
||||
"agents": { "demo": { "skills": [], "prompt": "", "bottle": "dev" } }
|
||||
}
|
||||
JSON
|
||||
|
||||
eff="$(pipelock_effective_allowlist "$combined" dev)"
|
||||
assert_contains "$eff" "api.anthropic.com" "effective: baked-in default present"
|
||||
assert_contains "$eff" "registry.npmjs.org" "effective: bottle egress entry present"
|
||||
assert_contains "$eff" "100.78.141.42" "effective: ssh ipv4 hostname present"
|
||||
assert_contains "$eff" "github.com" "effective: ssh hostname present"
|
||||
|
||||
# Ensure dedup + sort: count lines, then count unique lines, expect equal.
|
||||
total="$(printf '%s\n' "$eff" | wc -l | tr -d ' ')"
|
||||
uniq="$(printf '%s\n' "$eff" | sort -u | wc -l | tr -d ' ')"
|
||||
assert_eq "$total" "$uniq" "effective: deduplicated"
|
||||
|
||||
rm -f "$combined"
|
||||
|
||||
# --- non-string entry rejection ---
|
||||
|
||||
bad="$(mktemp)"
|
||||
cat > "$bad" <<'JSON'
|
||||
{
|
||||
"bottles": { "dev": { "egress": { "allowlist": ["github.com", 42] } } },
|
||||
"agents": { "demo": { "skills": [], "prompt": "", "bottle": "dev" } }
|
||||
}
|
||||
JSON
|
||||
|
||||
assert_exit_nonzero "bottle_allowlist: rejects non-string entry" \
|
||||
bash -c '. "'"${REPO_ROOT}"'/lib/log.sh"; . "'"${REPO_ROOT}"'/lib/pipelock.sh"; pipelock_bottle_allowlist "'"$bad"'" dev'
|
||||
rm -f "$bad"
|
||||
|
||||
test_summary
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# Unit: _pipelock_is_ipv4_literal — the classifier that decides
|
||||
# whether bottle.ssh[].Hostname goes into ssrf.ip_allowlist (IPv4
|
||||
# literal) or trusted_domains (hostname).
|
||||
TEST_NAME="pipelock_classify"
|
||||
|
||||
. "$(dirname "$0")/../lib/common.sh"
|
||||
# shellcheck source=../../lib/log.sh
|
||||
. "${REPO_ROOT}/lib/log.sh"
|
||||
# shellcheck source=../../lib/pipelock.sh
|
||||
. "${REPO_ROOT}/lib/pipelock.sh"
|
||||
|
||||
# Positive cases — these should be classified as IPv4 literals.
|
||||
for ip in "127.0.0.1" "10.0.0.5" "100.78.141.42" "0.0.0.0" "255.255.255.255"; do
|
||||
assert_exit_zero "ipv4: ${ip}" _pipelock_is_ipv4_literal "$ip"
|
||||
done
|
||||
|
||||
# Negative cases — hostnames, partial IPs, IPv6, and edge garbage
|
||||
# should NOT match.
|
||||
for hn in \
|
||||
"github.com" \
|
||||
"gitea.dideric.is" \
|
||||
"100.78.141" \
|
||||
"100.78.141.42.5" \
|
||||
"::1" \
|
||||
"fe80::1" \
|
||||
"localhost" \
|
||||
"" \
|
||||
"1.2.3.4.example.com"
|
||||
do
|
||||
assert_exit_nonzero "non-ipv4: '${hn}'" _pipelock_is_ipv4_literal "$hn"
|
||||
done
|
||||
|
||||
test_summary
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# Unit: pipelock naming helpers (container_name, proxy_url, proxy_host_port).
|
||||
TEST_NAME="pipelock_naming"
|
||||
|
||||
. "$(dirname "$0")/../lib/common.sh"
|
||||
# shellcheck source=../../lib/log.sh
|
||||
. "${REPO_ROOT}/lib/log.sh"
|
||||
# shellcheck source=../../lib/pipelock.sh
|
||||
. "${REPO_ROOT}/lib/pipelock.sh"
|
||||
|
||||
assert_eq "claude-bottle-pipelock-foo" "$(pipelock_container_name foo)" "container_name simple slug"
|
||||
assert_eq "claude-bottle-pipelock-some-slug" "$(pipelock_container_name some-slug)" "container_name with hyphens"
|
||||
|
||||
# proxy_url and proxy_host_port use whatever CLAUDE_BOTTLE_PIPELOCK_PORT
|
||||
# is at source time. We sourced with default (8888).
|
||||
assert_eq "http://claude-bottle-pipelock-foo:8888" "$(pipelock_proxy_url foo)" "proxy_url default port"
|
||||
assert_eq "claude-bottle-pipelock-foo:8888" "$(pipelock_proxy_host_port foo)" "proxy_host_port default port"
|
||||
|
||||
# Both helpers should fail loudly without a slug (the `${1:?...}` guards).
|
||||
assert_exit_nonzero "container_name: missing slug" bash -c '. "'"${REPO_ROOT}"'/lib/log.sh"; . "'"${REPO_ROOT}"'/lib/pipelock.sh"; pipelock_container_name'
|
||||
assert_exit_nonzero "proxy_url: missing slug" bash -c '. "'"${REPO_ROOT}"'/lib/log.sh"; . "'"${REPO_ROOT}"'/lib/pipelock.sh"; pipelock_proxy_url'
|
||||
|
||||
test_summary
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
# Unit: pipelock_write_yaml — produces a YAML config containing the
|
||||
# expected top-level keys and per-bottle entries. We don't fully parse
|
||||
# YAML (no yq dependency); we grep for content shape.
|
||||
TEST_NAME="pipelock_yaml"
|
||||
|
||||
. "$(dirname "$0")/../lib/common.sh"
|
||||
# shellcheck source=../../lib/log.sh
|
||||
. "${REPO_ROOT}/lib/log.sh"
|
||||
# shellcheck source=../../lib/pipelock.sh
|
||||
. "${REPO_ROOT}/lib/pipelock.sh"
|
||||
|
||||
out_dir="$(mktemp -d)"
|
||||
cleanup() { rm -rf "$out_dir"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# --- minimal bottle (no egress, no ssh): only api_allowlist defaults ---
|
||||
|
||||
m_min="$(write_fixture fixture_minimal)"
|
||||
yaml_min="${out_dir}/min.yaml"
|
||||
pipelock_write_yaml "$m_min" dev "$yaml_min"
|
||||
|
||||
content="$(cat "$yaml_min")"
|
||||
assert_contains "$content" "mode: strict" "min: mode strict"
|
||||
assert_contains "$content" "enforce: true" "min: enforce true"
|
||||
assert_contains "$content" "api_allowlist:" "min: api_allowlist block"
|
||||
assert_contains "$content" "api.anthropic.com" "min: anthropic baked default"
|
||||
assert_contains "$content" "raw.githubusercontent.com" "min: github raw baked default"
|
||||
assert_contains "$content" "forward_proxy:" "min: forward_proxy block"
|
||||
assert_contains "$content" "enabled: true" "min: forward_proxy enabled"
|
||||
assert_contains "$content" "dlp:" "min: dlp block"
|
||||
assert_contains "$content" "include_defaults: true" "min: dlp include_defaults"
|
||||
assert_contains "$content" "scan_env: true" "min: dlp scan_env"
|
||||
# No ssh entries in the manifest, so neither ssrf nor trusted_domains
|
||||
# blocks should be emitted.
|
||||
assert_not_contains "$content" "trusted_domains:" "min: no trusted_domains"
|
||||
assert_not_contains "$content" "ssrf:" "min: no ssrf block"
|
||||
|
||||
rm -f "$m_min"
|
||||
|
||||
# --- ssh bottle: trusted_domains for hostname, ssrf.ip_allowlist for ipv4 ---
|
||||
|
||||
m_ssh="$(write_fixture fixture_with_ssh)"
|
||||
yaml_ssh="${out_dir}/ssh.yaml"
|
||||
pipelock_write_yaml "$m_ssh" dev "$yaml_ssh"
|
||||
|
||||
content="$(cat "$yaml_ssh")"
|
||||
assert_contains "$content" "trusted_domains:" "ssh: trusted_domains block emitted"
|
||||
assert_contains "$content" "github.com" "ssh: hostname in trusted_domains (or allowlist)"
|
||||
assert_contains "$content" "ssrf:" "ssh: ssrf block emitted"
|
||||
assert_contains "$content" "ip_allowlist:" "ssh: ip_allowlist key under ssrf"
|
||||
assert_contains "$content" "100.78.141.42/32" "ssh: ipv4 host emitted as /32"
|
||||
# Belt-and-suspenders: the ipv4 host should also be in api_allowlist
|
||||
# (strict mode requires both).
|
||||
assert_contains "$content" "100.78.141.42" "ssh: ipv4 host in api_allowlist too"
|
||||
|
||||
rm -f "$m_ssh"
|
||||
|
||||
# --- secret hygiene: env values from the manifest never enter the YAML ---
|
||||
|
||||
m_secret="$(mktemp)"
|
||||
cat > "$m_secret" <<'JSON'
|
||||
{
|
||||
"bottles": {
|
||||
"dev": {
|
||||
"env": {
|
||||
"MY_SECRET": "literal-value-should-not-appear",
|
||||
"ANOTHER": "?prompt-message"
|
||||
},
|
||||
"egress": { "allowlist": ["github.com"] }
|
||||
}
|
||||
},
|
||||
"agents": { "demo": { "skills": [], "prompt": "", "bottle": "dev" } }
|
||||
}
|
||||
JSON
|
||||
yaml_sec="${out_dir}/secret.yaml"
|
||||
pipelock_write_yaml "$m_secret" dev "$yaml_sec"
|
||||
content="$(cat "$yaml_sec")"
|
||||
assert_not_contains "$content" "literal-value-should-not-appear" "secret: literal env value not leaked"
|
||||
assert_not_contains "$content" "MY_SECRET" "secret: env var name not leaked"
|
||||
assert_not_contains "$content" "prompt-message" "secret: prompt sentinel not leaked"
|
||||
rm -f "$m_secret"
|
||||
|
||||
# --- file mode is 600 ---
|
||||
mode="$(stat -f '%p' "$yaml_min" 2>/dev/null || stat -c '%a' "$yaml_min")"
|
||||
# macOS stat -f '%p' returns full mode like 100600; trim. Linux stat -c '%a' gives just 600.
|
||||
mode="${mode: -3}"
|
||||
assert_eq "600" "$mode" "yaml file mode is 600"
|
||||
|
||||
test_summary
|
||||
Reference in New Issue
Block a user