Skip to content
59 changes: 43 additions & 16 deletions package-firewall/bash/generate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -45,24 +45,28 @@ SHARED_BLOCKS_DIR="$SCRIPT_DIR/../shared/blocks"
: "${ENDOR_API_KEY_ID:?ENDOR_API_KEY_ID is required}"
: "${ENDOR_API_SECRET:?ENDOR_API_SECRET is required}"

# Reject credential characters that would corrupt generated scripts/URLs.
case "${ENDOR_API_KEY_ID}${ENDOR_API_SECRET}" in
*[!A-Za-z0-9+/=_.-]*)
echo "ERROR: ENDOR_API_KEY_ID / ENDOR_API_SECRET contain unsupported characters" >&2
exit 1 ;;
esac

# ─── Resolve FQDN ─────────────────────────────────────────────────────────────
FQDN="${ENDOR_FQDN:-https://factory.endorlabs.com}"

# ─── Compute derived values ────────────────────────────────────────────────────
# Only machine-independent values are derived here. Attribution values
# (<console-user>@<machine>) are computed at install time — see credentials_block.
FQDN_HOST="${FQDN#https://}"
FQDN_HOST="${FQDN_HOST#http://}"
TRUSTED_HOST="${FQDN_HOST%%:*}"

NPM_REGISTRY_URL="${FQDN}/v1/namespaces/${ENDOR_NAMESPACE}/firewall/npm/"
NPM_REGISTRY_HOST="${FQDN_HOST}/v1/namespaces/${ENDOR_NAMESPACE}/firewall/npm/"
NPM_AUTH_B64=$(printf '%s' "${ENDOR_API_KEY_ID}:${ENDOR_API_SECRET}" | base64 | tr -d '\n')
API_SECRET_B64=$(printf '%s' "${ENDOR_API_SECRET}" | base64 | tr -d '\n')

PYPI_URL="${FQDN}/v1/namespaces/${ENDOR_NAMESPACE}/firewall/pypi/simple/"
PIP_INDEX_URL="https://${ENDOR_API_KEY_ID}:${ENDOR_API_SECRET}@${FQDN_HOST}/v1/namespaces/${ENDOR_NAMESPACE}/firewall/pypi/simple/"

GO_PROXY_URL="https://${ENDOR_API_KEY_ID}:${ENDOR_API_SECRET}@${FQDN_HOST}/v1/namespaces/${ENDOR_NAMESPACE}/firewall/go/,direct"
MAVEN_REGISTRY_URL="${FQDN}/v1/namespaces/${ENDOR_NAMESPACE}/firewall/maven/"
API_SECRET_B64=$(printf '%s' "${ENDOR_API_SECRET}" | base64 | tr -d '\n')

# ─── Output directory ─────────────────────────────────────────────────────────
OUT_DIR="${SCRIPT_DIR}/out/${ENDOR_NAMESPACE}"
Expand All @@ -74,16 +78,13 @@ substitute() {
-e "s|{{NAMESPACE}}|${ENDOR_NAMESPACE}|g" \
-e "s|{{API_KEY_ID}}|${ENDOR_API_KEY_ID}|g" \
-e "s|{{API_SECRET}}|${ENDOR_API_SECRET}|g" \
-e "s|{{API_SECRET_B64}}|${API_SECRET_B64}|g" \
-e "s|{{FQDN}}|${FQDN}|g" \
-e "s|{{FQDN_HOST}}|${FQDN_HOST}|g" \
-e "s|{{NPM_REGISTRY_URL}}|${NPM_REGISTRY_URL}|g" \
-e "s|{{NPM_REGISTRY_HOST}}|${NPM_REGISTRY_HOST}|g" \
-e "s|{{NPM_AUTH_B64}}|${NPM_AUTH_B64}|g" \
-e "s|{{API_SECRET_B64}}|${API_SECRET_B64}|g" \
-e "s|{{PYPI_URL}}|${PYPI_URL}|g" \
-e "s|{{PIP_INDEX_URL}}|${PIP_INDEX_URL}|g" \
-e "s|{{ENDOR_PYPI_URL}}|${PIP_INDEX_URL}|g" \
-e "s|{{TRUSTED_HOST}}|${TRUSTED_HOST}|g" \
-e "s|{{GO_PROXY_URL}}|${GO_PROXY_URL}|g" \
-e "s|{{MAVEN_REGISTRY_URL}}|${MAVEN_REGISTRY_URL}|g"
}

Expand All @@ -108,10 +109,8 @@ emit_block_assignment() {
echo ")"
}

# emit_all_blocks
# Emits all block variable assignments into the generated script.
# Edit shared/blocks/*.txt to change shared config content.
# Edit templates/envsh.txt to change the bash-only env var block.
# emit_all_blocks — emits all block variable assignments into the generated
# script. Attribution {{...}} tokens are filled at install time by the templates.
emit_all_blocks() {
echo "# ── Block content (from shared/blocks/) ─────────────────────────────────────"
emit_block_assignment "ENVSH_BLOCK" "$SHARED_BLOCKS_DIR/envsh.txt"
Expand Down Expand Up @@ -162,6 +161,30 @@ USER_GROUP=$(id -gn "$CONSOLE_USER" 2>/dev/null || echo "staff")
USERBLOCK
}

# credentials_block — attribution values computed on the dev machine at install
# time (the <console-user>@<machine> label doesn't exist at generation time).
credentials_block() {
substitute << 'CREDBLOCK'
# ── User attribution (computed at install time) ───────────────────────────────
ENDOR_API_KEY_ID='{{API_KEY_ID}}'
ENDOR_API_SECRET='{{API_SECRET}}'

ENDOR_ATTR_LABEL="${CONSOLE_USER}@$(endor_host_label)"
ENDOR_ATTR_USER="$(endor_attr_username "$ENDOR_ATTR_LABEL" "$ENDOR_API_KEY_ID")"

# npm _auth = base64(username:password)
ENDOR_AUTH_B64="$(printf '%s:%s' "$ENDOR_ATTR_USER" "$ENDOR_API_SECRET" | endor_b64)"

# pip / uv / go URLs: percent-encode both userinfo halves ('/' would break the URL).
ENDOR_PYPI_URL="https://$(endor_urlenc_b64 "$ENDOR_ATTR_USER"):$(endor_urlenc_b64 "$ENDOR_API_SECRET")@{{FQDN_HOST}}/v1/namespaces/{{NAMESPACE}}/firewall/pypi/simple/"
ENDOR_GO_PROXY_URL="https://$(endor_urlenc_b64 "$ENDOR_ATTR_USER"):$(endor_urlenc_b64 "$ENDOR_API_SECRET")@{{FQDN_HOST}}/v1/namespaces/{{NAMESPACE}}/firewall/go/,direct"

# No exports — every consumer is same-process template code inlined below.

echo "[endor] user attribution → ${ENDOR_ATTR_LABEL}"
CREDBLOCK
}

# script_header <output> <description>
script_header() {
local output="$1"
Expand Down Expand Up @@ -192,6 +215,8 @@ build_script() {

{
script_header "$output" "$description"
credentials_block
echo ""
emit_all_blocks
echo "# ════════════════════════════════════════════════════════════════════════════"
echo "# Env setup"
Expand Down Expand Up @@ -247,6 +272,8 @@ build_remove_script "$OUT_DIR/endor-remove.sh"
{
script_header "$OUT_DIR/endor-all.sh" \
"Configures all package managers for Endor Package Firewall. Covers: npm · pnpm · yarn classic · yarn 2+ · bun · pip · uv · poetry · go · maven"
credentials_block
echo ""
emit_all_blocks
echo "# ════════════════════════════════════════════════════════════════════════════"
echo "# Env setup"
Expand Down Expand Up @@ -295,7 +322,7 @@ echo " All scripts accept --dry-run to preview changes without writing anythin
echo " Upload to your MDM tool. Each script is self-contained and idempotent."
echo ""
echo " To customise: edit shared/blocks/*.txt (shared config content)"
echo " or templates/envsh.txt (bash env var block)"
echo " or shared/blocks/envsh.txt (bash env var block)"
echo " or templates/*.sh (orchestration logic)"
echo ""
echo " Re-running overwrites the same output directory."
129 changes: 117 additions & 12 deletions package-firewall/bash/lib/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
# resolve_user_home <user> — resolves home via dscl / getent / POSIX
# upsert_block <file> <content> <owner> <group>
# — non-destructive, idempotent sentinel-block writer
# merges into an existing [global] for pip.conf
# honours DRY_RUN=1 (prints intent, no writes)
# remove_block <file> <owner> <group>
# — strips Endor sentinel block from a file
# — strips Endor sentinel block from a file,
# restores keys disabled with '#endor-bak#'
# honours DRY_RUN=1 (prints intent, no writes)
# warn_if_key_conflict <file> <pattern> <label>
# — warns when a key exists outside an Endor block
Expand All @@ -24,6 +26,38 @@ ENDOR_BLOCK_END="# ===== END ENDOR PACKAGE FIREWALL ====="
ENDOR_XML_BLOCK_START="<!-- ===== BEGIN ENDOR PACKAGE FIREWALL (managed — do not edit) ===== -->"
ENDOR_XML_BLOCK_END="<!-- ===== END ENDOR PACKAGE FIREWALL ===== -->"

# ── User attribution helpers ──────────────────────────────────────────────────
# Encode <console-user>@<machine> into the Basic-auth username. The firewall
# decodes the label, auths with the real API key, and logs it as "User".

# endor_b64 — portable base64, no line wrapping (GNU wraps at 76 cols; BSD doesn't).
endor_b64() {
if base64 --help 2>&1 | grep -q -- '-w'; then
base64 -w0
else
base64 | tr -d '\n'
fi
}

# endor_urlenc_b64 <b64> — percent-encode base64 chars (+ / =) for URL userinfo.
endor_urlenc_b64() {
printf '%s' "$1" | sed -e 's/+/%2B/g' -e 's#/#%2F#g' -e 's/=/%3D/g'
}

# endor_host_label — a stable, human-readable machine name for attribution.
endor_host_label() {
scutil --get ComputerName 2>/dev/null || hostname 2>/dev/null || echo unknown
}

# endor_attr_username <label> <api_key_id>
# Returns base64(base64("userattr:"+label)+":"+keyId) — the format
# decodeAttributedUsername() expects in endorfactory's auth layer.
endor_attr_username() {
local label="$1" key_id="$2" inner
inner=$(printf '%s' "userattr:${label}" | endor_b64)
printf '%s:%s' "$inner" "$key_id" | endor_b64
}

# detect_console_user
# MDM tools (Kandji, Jamf) run scripts as root. $HOME resolves to /var/root, which
# is not where developer config files live. Returns the name of the actual logged-in
Expand Down Expand Up @@ -76,20 +110,46 @@ resolve_user_home() {
# - File present, no block → appends the block; existing content untouched
# - File present, block found → replaces only the block; rest untouched
# - DRY_RUN=1 → prints what would happen, writes nothing
#
# pip.conf special case: pip rejects duplicate [global] sections, so when both
# <content> and the file declare [global], the keys are merged into the existing
# section and conflicting keys are disabled reversibly with '#endor-bak#'.
upsert_block() {
local file="$1"
local content="$2"
local owner="$3"
local group="$4"
local tmp merge=0

# Strip CRLF (block files are shared with the Windows generator).
content=${content//$'\r'/}

# Merge only when both the content and the pre-existing file declare [global]
if printf '%s\n' "$content" | grep -q '^\[global\]$' \
&& [[ -f "$file" ]] \
&& awk -v start="$ENDOR_BLOCK_START" -v end="$ENDOR_BLOCK_END" '
index($0, start) { skip=1; next }
index($0, end) { skip=0; next }
!skip && /^\[global\]/ { found=1 }
END { exit !found }
' "$file" 2>/dev/null; then
merge=1
fi

if [[ "${DRY_RUN:-0}" == "1" ]]; then
if [[ -f "$file" ]] && grep -qF "$ENDOR_BLOCK_START" "$file" 2>/dev/null; then
echo "[dry-run] action : REPLACE existing Endor block"
elif [[ "$merge" -eq 1 ]]; then
echo "[dry-run] action : MERGE into existing [global] (conflicting keys disabled via #endor-bak#)"
elif [[ -f "$file" ]]; then
echo "[dry-run] action : APPEND Endor block to existing file"
else
echo "[dry-run] action : CREATE file with Endor block"
fi
if [[ "$merge" -eq 1 ]]; then
echo "[dry-run] note : pre-existing index keys will be disabled via '#endor-bak#'"
echo "[dry-run] and the Endor keys merged into the existing [global]"
fi
echo "[dry-run] file : $file"
echo "[dry-run] content:"
echo "$content" | sed 's/^/[dry-run] /'
Expand All @@ -101,7 +161,6 @@ upsert_block() {

# Strip any existing Endor block, preserving everything else
if [[ -f "$file" ]] && grep -qF "$ENDOR_BLOCK_START" "$file" 2>/dev/null; then
local tmp
tmp=$(mktemp)
awk -v start="$ENDOR_BLOCK_START" -v end="$ENDOR_BLOCK_END" '
index($0, start) { skip=1; next }
Expand All @@ -111,10 +170,51 @@ upsert_block() {
mv "$tmp" "$file"
fi

printf '\n%s\n%s\n%s\n' \
"$ENDOR_BLOCK_START" \
"$content" \
"$ENDOR_BLOCK_END" >> "$file"
if [[ "$merge" -eq 1 ]]; then
# Disable-keys derived from the content; pip treats '-'/'_' and '='/':' alike.
local keys_re
keys_re=$(printf '%s\n' "$content" | sed -nE 's/^([A-Za-z0-9_-]+)[[:space:]]*[=:].*/\1/p' \
| sed 's/[-_]/[-_]/g' | paste -sd'|' -)

# 1. Reversibly disable pre-existing copies of those keys and their
# indented continuation lines.
if [[ -n "$keys_re" ]] && grep -qE "^[[:space:]]*(${keys_re})[[:space:]]*[=:]" "$file"; then
tmp=$(mktemp)
awk -v re="^[[:space:]]*(${keys_re})[[:space:]]*[=:]" '
$0 ~ re { print "#endor-bak#" $0; cont=1; next }
cont && /^[[:space:]]+[^[:space:]]/ { print "#endor-bak#" $0; next }
{ cont=0; print }
' "$file" > "$tmp"
mv "$tmp" "$file"
echo "[endor] NOTE: existing pip index keys in ${file} disabled with '#endor-bak#' (restored on removal)"
fi

# 2. Insert the sentinel-wrapped keys after the first [global]
# (temp file + getline: BSD awk rejects multi-line -v).
local keyfile
keyfile=$(mktemp)
{
echo "$ENDOR_BLOCK_START"
printf '%s\n' "$content" | grep -v '^\[global\]'
echo "$ENDOR_BLOCK_END"
} > "$keyfile"
tmp=$(mktemp)
awk -v keyfile="$keyfile" '
{ print }
/^\[global\]/ && !done {
while ((getline line < keyfile) > 0) print line
close(keyfile)
done=1
}
' "$file" > "$tmp"
mv "$tmp" "$file"
rm -f "$keyfile"
else
printf '\n%s\n%s\n%s\n' \
"$ENDOR_BLOCK_START" \
"$content" \
"$ENDOR_BLOCK_END" >> "$file"
fi

chown "$owner:$group" "$file"
chmod 600 "$file"
Expand Down Expand Up @@ -197,11 +297,11 @@ upsert_xml_block() {

# remove_block <file> <owner> <group>
#
# Strips the Endor sentinel block from a config file.
# Strips the Endor sentinel block and restores '#endor-bak#'-disabled keys.
# - File absent → skips silently
# - No Endor block found → skips with a notice
# - Block found → removes block; preserves everything else
# - File empty after removal → deletes the file entirely
# - File empty after removal → deletes it (a bare [global] counts as empty)
# - DRY_RUN=1 → prints what would happen, writes nothing
remove_block() {
local file="$1"
Expand All @@ -227,11 +327,14 @@ remove_block() {
!skip { print }
' "$file" | tr -d '[:space:]')

if [[ -z "$remaining" ]]; then
if [[ -z "$remaining" || "$remaining" == "[global]" ]]; then
echo "[dry-run] action : REMOVE block → file would be empty → DELETE file"
else
echo "[dry-run] action : REMOVE block, preserve remaining content"
fi
if grep -qF '#endor-bak#' "$file" 2>/dev/null; then
echo "[dry-run] restore: keys disabled with '#endor-bak#'"
fi
echo "[dry-run] file : $file"
echo ""
return 0
Expand All @@ -243,10 +346,12 @@ remove_block() {
index($0, start) { skip=1; next }
index($0, end) { skip=0; next }
!skip { print }
' "$file" > "$tmp"
' "$file" | sed -E 's/^([[:space:]]*)#endor-bak#/\1/' > "$tmp"

# Delete the file if it's effectively empty after block removal
if [[ -z "$(tr -d '[:space:]' < "$tmp")" ]]; then
# Delete if effectively empty (whitespace only, or a bare [global])
local remaining
remaining=$(tr -d '[:space:]' < "$tmp")
if [[ -z "$remaining" || "$remaining" == "[global]" ]]; then
rm -f "$file" "$tmp"
echo "[endor-remove] deleted (was empty) : $file"
else
Expand Down
18 changes: 14 additions & 4 deletions package-firewall/bash/templates/envsh.sh
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
# templates/envsh.sh
# Writes ~/.config/endor/env.sh — the single credential source for all
# env-var-based tools (npm, uv, yarn 2+, poetry; Go in future).
# env-var-based tools (npm, yarn 2+, maven, poetry).
# Then adds a one-line source directive to existing shell profiles.
#
# Block content is defined in templates/blocks/envsh.txt.
# pip is intentionally excluded — pip.conf does not support env var expansion,
# so pip credentials are written as literal values in python.sh instead.
# Block content comes from shared/blocks/envsh.txt; attribution tokens are
# filled at install time. Leftover {{...}} tokens warn and exit non-zero.
# pip / uv / go don't read env.sh — their configs get baked literals instead.

echo ""
echo "[endor] ── env.sh setup ─────────────────────────────────────────────────────"

ENDOR_ENV_SH="$USER_HOME/.config/endor/env.sh"

# Fill the attribution-dependent tokens (values from the credentials block).
ENVSH_BLOCK=${ENVSH_BLOCK//'{{ATTR_USER}}'/"$ENDOR_ATTR_USER"}
ENVSH_BLOCK=${ENVSH_BLOCK//'{{NPM_AUTH_B64}}'/"$ENDOR_AUTH_B64"}

if [[ "$ENVSH_BLOCK" == *'{{'* ]]; then
echo "[endor] WARNING: unresolved {{...}} token in env.sh block — a token in" >&2
echo "[endor] shared/blocks/envsh.txt has no fill in templates/envsh.sh." >&2
_ENDOR_WARNED=1
fi

upsert_block \
"$ENDOR_ENV_SH" \
"$ENVSH_BLOCK" \
Expand Down
7 changes: 5 additions & 2 deletions package-firewall/bash/templates/go.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
# Linux → ~/.config/go/env (or $XDG_CONFIG_HOME/go/env)
#
# Block content is defined in shared/blocks/goenv.txt.
# {{GO_PROXY_URL}} is substituted at generation time — Go env files do not support
# env var expansion, so credentials are baked into the GOPROXY value.
# Go env files can't expand env vars, so the {{GO_PROXY_URL}} token is filled
# here at install time with the attributed literal (from the credentials block).
#
# The go env file takes lower precedence than the GOPROXY process env var, so
# project-level overrides (go env -w GOPROXY=... in a workspace) remain possible.
Expand All @@ -17,6 +17,9 @@
echo ""
echo "[endor-go] ── Go package manager ───────────────────────────────────────────"

# Fill the attributed GOPROXY into the block content (go env can't expand env vars).
GO_BLOCK=${GO_BLOCK//'{{GO_PROXY_URL}}'/"$ENDOR_GO_PROXY_URL"}

# ── Resolve go env file path ──────────────────────────────────────────────────
# Run `go env GOENV` with the user's HOME so Go's os.UserConfigDir() returns
# the correct user-specific path (macOS: ~/Library/Application Support/go/env,
Expand Down
Loading