diff --git a/lib/pipelock.sh b/lib/pipelock.sh index 0a64f10..187175f 100644 --- a/lib/pipelock.sh +++ b/lib/pipelock.sh @@ -165,6 +165,50 @@ pipelock_bottle_ssh_hostnames() { ' "$manifest_file" } +# _pipelock_is_ipv4_literal — exit 0 if looks like an IPv4 +# literal (four dot-separated octets). Pipelock's SSRF check fires on +# the resolved IP, so a Hostname that's already an IP literal needs +# `ssrf.ip_allowlist`, while a hostname needs `trusted_domains`. +_pipelock_is_ipv4_literal() { + local s="${1:?}" + [[ "$s" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] +} + +# pipelock_bottle_ssh_trusted_domains +# +# Hostname-shaped ssh[].Hostname entries that should bypass pipelock's +# SSRF check (so a name resolving to a private IP — e.g. internal API +# behind a VPN — is reachable). IP-literal entries are excluded; +# trusted_domains is hostname-based per pipelock's docs. +pipelock_bottle_ssh_trusted_domains() { + local manifest_file="${1:?}" + local bottle_name="${2:?}" + local h + while IFS= read -r h; do + [ -z "$h" ] && continue + _pipelock_is_ipv4_literal "$h" && continue + printf '%s\n' "$h" + done < <(pipelock_bottle_ssh_hostnames "$manifest_file" "$bottle_name") +} + +# pipelock_bottle_ssh_ip_cidrs +# +# Emits one canonical /32 CIDR per IPv4-literal ssh[].Hostname so they +# pass pipelock's SSRF IP-range check (which blocks RFC 1918, RFC 6598 +# CGNAT, link-local, loopback, etc. by default). Hostnames are skipped +# — they go through trusted_domains instead. +pipelock_bottle_ssh_ip_cidrs() { + local manifest_file="${1:?}" + local bottle_name="${2:?}" + local h + while IFS= read -r h; do + [ -z "$h" ] && continue + if _pipelock_is_ipv4_literal "$h"; then + printf '%s/32\n' "$h" + fi + done < <(pipelock_bottle_ssh_hostnames "$manifest_file" "$bottle_name") +} + # pipelock_effective_allowlist # # Prints the deduplicated union of: the baked-in default allowlist, the @@ -285,6 +329,32 @@ pipelock_write_yaml() { printf 'forward_proxy:\n' printf ' enabled: true\n' printf '\n' + # SSRF exemptions for declared SSH hosts. Pipelock blocks the CGNAT + # range (100.64.0.0/10, where Tailscale IPs live) and the rest of + # RFC 1918 / link-local by default. Hostname entries go to + # trusted_domains; IP-literal entries to ssrf.ip_allowlist as /32. + local trusted_count=0 ssrf_count=0 + local td + while IFS= read -r td; do + [ -z "$td" ] && continue + if [ "$trusted_count" -eq 0 ]; then + printf 'trusted_domains:\n' + fi + printf ' - "%s"\n' "$td" + trusted_count=$((trusted_count + 1)) + done < <(pipelock_bottle_ssh_trusted_domains "$manifest_file" "$bottle_name") + [ "$trusted_count" -gt 0 ] && printf '\n' + local cidr + while IFS= read -r cidr; do + [ -z "$cidr" ] && continue + if [ "$ssrf_count" -eq 0 ]; then + printf 'ssrf:\n' + printf ' ip_allowlist:\n' + fi + printf ' - "%s"\n' "$cidr" + ssrf_count=$((ssrf_count + 1)) + done < <(pipelock_bottle_ssh_ip_cidrs "$manifest_file" "$bottle_name") + [ "$ssrf_count" -gt 0 ] && printf '\n' printf 'dlp:\n' printf ' include_defaults: true\n' printf ' scan_env: true\n'