vault-ops/infra/versions/vault-inventory-report-v1.0.sh
2026-04-14 11:45:15 +07:00

462 lines
16 KiB
Bash
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
set -Eeuo pipefail
# vault-inventory-report.sh — Inventory & Relations Report for HashiCorp Vault
# - Auth mounts
# - Cert-Auth mappings (inkl. debug-policy detection)
# - AppRole roles (robust, still works ohne List-Rechte)
# - Policies: Pfade & pki-test/(issue|sign)/<role> Referenzen (+ Wildcards)
# - Cross-Refs: Cert/AppRole ↔ Policy, Policy → PKI-Role
# - PKI roles: USED zuerst (mit referenzierenden Policies), dann FREE
# - Missing policy detection für Cert/AppRole
# - JSON report + optional Graphviz .dot
# ===== Pretty logs =====
if [[ -t 1 ]]; then B=$'\e[1m'; R=$'\e[0m'; else B= R=; fi
ts(){ date +"%Y-%m-%d %H:%M:%S"; }
info(){ echo -e "🟦 ${B}[$(ts)]${R} $*"; }
ok(){ echo -e "🟩 ${B}[$(ts)]${R} $*"; }
warn(){ echo -e "🟨 ${B}[$(ts)]${R} $*"; }
err(){ echo -e "🟥 ${B}[$(ts)]${R} $*" >&2; }
need(){ command -v "$1" >/dev/null 2>&1 || { err "missing tool: $1"; exit 2; }; }
need vault
need jq
need grep
need sed
need awk
need sort
STAMP="$(date +%F_%H%M%S)"
OUT_JSON="/tmp/vault-inventory-${STAMP}.json"
OUT_DOT="/tmp/vault-inventory-${STAMP}.dot"
# ===== temp files registry =====
TEMPS=()
mk(){ local f; f="$(mktemp)"; TEMPS+=("$f"); echo -n "$f"; }
cleanup(){ local t; for t in "${TEMPS[@]:-}"; do rm -f "$t" 2>/dev/null || true; done; }
trap cleanup EXIT
CERT_ND="$(mk)" # NDJSON (cert mappings)
APPROLE_ND="$(mk)" # NDJSON (approles)
PKI_ND="$(mk)" # NDJSON (pki roles)
POL_RAW_DIR="$(mktemp -d)"; TEMPS+=("$POL_RAW_DIR")
POL_ND="$(mk)" # NDJSON (policy metadata)
# helper: NDJSON -> JSON-Array robust (leer => [])
ndjson_to_array() {
local in="$1" out="$2"
if [[ -s "$in" ]]; then
jq -s '.' "$in" > "$out"
else
printf '[]' > "$out"
fi
}
# ===== Reachability =====
if ! S="$(vault status -format=json 2>/dev/null)"; then
err "Vault nicht erreichbar (VAULT_ADDR/VAULT_CACERT korrekt gesetzt?)"
exit 1
fi
VERS="$(jq -r '.version' <<<"$S")"
SEALED="$(jq -r '.sealed' <<<"$S")"
ok "Vault erreichbar (version=${VERS}, sealed=${SEALED})"
# ===== Auth mounts (kurz) =====
if AM="$(vault auth list -detailed -format=json 2>/dev/null)"; then
info "Auth-Mounts (kurz):"
jq -r 'to_entries[] | " 📌 \(.key)\tplugin=\(.value.type)\taccessor=\(.value.accessor)"' <<<"$AM"
else
warn "konnte Auth-Mounts nicht lesen"
fi
# ===== Cert-Auth Mappings =====
CERT_NAMES=()
if MAPS="$(vault list -format=json auth/cert/certs 2>/dev/null)"; then
info "Cert-Auth Mappings:"
if [[ "$MAPS" != "null" ]]; then
mapfile -t CERT_NAMES < <(jq -r '.[]' <<<"$MAPS")
for c in "${CERT_NAMES[@]}"; do
if ! J="$(vault read -format=json "auth/cert/certs/${c}" 2>/dev/null)"; then
warn " 🔐 ${c} (lesen fehlgeschlagen)"
continue
fi
disp="$(jq -r '.data.display_name // ""' <<<"$J")"
ttl="$(jq -r '.data.token_ttl // 0' <<<"$J")"
maxt="$(jq -r '.data.token_max_ttl // 0' <<<"$J")"
has_dbg="$(jq -r '((.data.policies // []) | index("debug-policy")) | if . == null then "false" else "true" end' <<<"$J")"
dbg_txt="NEIN"; [[ "$has_dbg" == "true" ]] && dbg_txt="JA"
echo " 🔐 ${c} (display=${disp:-"-"}, ttl=${ttl}s, max_ttl=${maxt}s) 🔎 debug-policy=${dbg_txt}"
acn="$(jq -r '(.data.allowed_common_names // []) | join(",")' <<<"$J")"
pol="$(jq -r '(.data.policies // []) | join(",")' <<<"$J")"
[[ -n "$acn" ]] && echo " ↳ allowed_common_names: ${acn}"
[[ -n "$pol" ]] && echo " ↳ policies: ${pol}"
jq -c --arg name "$c" '
{
name: $name,
display_name: (.data.display_name // null),
token_ttl: (.data.token_ttl // 0),
token_max_ttl: (.data.token_max_ttl // 0),
policies: (.data.policies // []),
allowed_common_names: (.data.allowed_common_names // []),
debug_policy: (((.data.policies // []) | index("debug-policy")) != null)
}
' <<<"$J" >>"$CERT_ND"
done
else
echo " (keine Einträge)"
fi
else
warn "konnte Cert-Auth-Mappings nicht auflisten"
fi
# ===== AppRole Rollen =====
APPROLE_NAMES=()
if ARLIST="$(vault list -format=json auth/approle/role 2>/dev/null)"; then
info "AppRole-Rollen:"
if [[ "$ARLIST" != "null" ]]; then
mapfile -t APPROLE_NAMES < <(jq -r '.[]' <<<"$ARLIST")
for r in "${APPROLE_NAMES[@]}"; do
if ! JR="$(vault read -format=json "auth/approle/role/${r}" 2>/dev/null)"; then
warn " 🔑 ${r} (lesen fehlgeschlagen)"
continue
fi
bsi="$(jq -r '.data.bind_secret_id // false' <<<"$JR")"
tpol="$(jq -r '(.data.token_policies // []) | join(",")' <<<"$JR")"
ttype="$(jq -r '.data.token_type // ""' <<<"$JR")"
period="$(jq -r '.data.period // "0"' <<<"$JR")"
sidttl="$(jq -r '.data.secret_id_ttl // 0' <<<"$JR")"
echo " 🔑 ${r} (bind_secret_id=${bsi}, token_type=${ttype}, period=${period}, secret_id_ttl=${sidttl})"
[[ -n "$tpol" ]] && echo " ↳ token_policies: ${tpol}"
jq -c --arg name "$r" '
{
name: $name,
bind_secret_id: (.data.bind_secret_id // false),
token_policies: (.data.token_policies // []),
token_type: (.data.token_type // null),
period: (.data.period // null),
secret_id_ttl: (.data.secret_id_ttl // 0)
}
' <<<"$JR" >>"$APPROLE_ND"
done
else
echo " (keine Einträge)"
fi
else
# leiser, neutraler Fallback (keine Rechte ODER keine Einträge)
info "AppRole-Rollen: (keine Einträge oder keine Berechtigung)"
fi
# ===== Policies einsammeln (HCL dump) =====
POLS=()
if PLIST="$(vault policy list -format=json 2>/dev/null)"; then
mapfile -t POLS < <(jq -r '.[]' <<<"$PLIST")
for p in "${POLS[@]}"; do
vault policy read "$p" >"$POL_RAW_DIR/$p.hcl" 2>/dev/null || true
done
else
warn "konnte Policies nicht auflisten Policy-Analyse ggf. unvollständig"
fi
# ===== Policies normalisieren (name, paths[], pki_issue_roles[], wildcard_issue_sign) =====
for p in "${POLS[@]:-}"; do
f="$POL_RAW_DIR/$p.hcl"
[[ -s "$f" ]] || continue
PPATHS=()
PROLES=()
HAS_WILDCARD=false
# alle path-Zeilen extrahieren (nur für Anzeige/Zwecke)
mapfile -t PPATHS < <(
grep -E '^[[:space:]]*path[[:space:]]*"' "$f" 2>/dev/null \
| sed -E 's/^[[:space:]]*path[[:space:]]*"([^"]+)".*/\1/' \
| sort -u
) || true
# issue|sign Rollen-Namen extrahieren (ohne *) Mount fest: pki-test
mapfile -t PROLES < <(
grep -Eo 'pki-test/(issue|sign)/([A-Za-z0-9._-]+|\*)' "$f" 2>/dev/null \
| awk -F/ '{print $NF}' \
| grep -v '^\*$' \
| sort -u
) || true
# Wildcard vorhanden?
if grep -Eq 'pki-test/(issue|sign)/\*' "$f" 2>/dev/null; then
HAS_WILDCARD=true
fi
# JSON bauen
if ((${#PPATHS[@]})); then PATHS_JSON="$(printf '%s\n' "${PPATHS[@]}" | jq -R . | jq -s .)"; else PATHS_JSON='[]'; fi
if ((${#PROLES[@]})); then ROLES_JSON="$(printf '%s\n' "${PROLES[@]}" | jq -R . | jq -s .)"; else ROLES_JSON='[]'; fi
jq -nc \
--arg name "$p" \
--argjson paths "$PATHS_JSON" \
--argjson issue_roles "$ROLES_JSON" \
--argjson wildcard "$HAS_WILDCARD" \
'{name:$name, paths:$paths, pki_issue_roles:$issue_roles, wildcard_issue_sign:$wildcard}' \
>>"$POL_ND"
done
# ===== PKI Rollen (pki-test/roles) + Policy-Ref-Analyse (USED -> FREE, deutlich) =====
MOUNT="pki-test" # zentral bei Bedarf später dynamisieren
PKI_ROLE_NAMES=()
if PRLIST="$(vault list -format=json ${MOUNT}/roles 2>/dev/null)"; then
info "PKI-Rollen (${MOUNT}/roles) — nach Policy-Referenzen sortiert:"
if [[ "$PRLIST" != "null" ]]; then
mapfile -t PKI_ROLE_NAMES < <(jq -r '.[]' <<<"$PRLIST")
for r in "${PKI_ROLE_NAMES[@]}"; do
if ! JR="$(vault read -format=json "${MOUNT}/roles/${r}" 2>/dev/null)"; then
warn " 📜 ${r} (lesen fehlgeschlagen)"
continue
fi
sflag="$(jq -r '.data.server_flag // false' <<<"$JR")"
cflag="$(jq -r '.data.client_flag // false' <<<"$JR")"
adom="$(jq -r '(.data.allowed_domains // []) | join(",")' <<<"$JR")"
asub="$(jq -r '.data.allow_subdomains // false' <<<"$JR")"
abare="$(jq -r '.data.allow_bare_domains // false' <<<"$JR")"
mttl="$(jq -r '.data.max_ttl // 0' <<<"$JR")"
# Referenzen: issue ODER sign, inkl. Wildcard
refs=()
if compgen -G "$POL_RAW_DIR/*.hcl" >/dev/null 2>&1; then
while IFS= read -r f; do
refs+=( "$(basename "${f%.hcl}")" )
done < <(grep -l -E "${MOUNT}/(issue|sign)/(${r}([^A-Za-z0-9._-]|$)|\*)" "$POL_RAW_DIR"/*.hcl 2>/dev/null || true)
fi
ref_count=${#refs[@]}
# JSON für Gesamtreport
if (( ref_count == 0 )); then
REFS_JSON="[]"
else
REFS_JSON="$(printf '%s\n' "${refs[@]}" | jq -R . | jq -s .)"
fi
jq -c \
--arg name "$r" \
--argjson refs "$REFS_JSON" \
--argjson refcount "$ref_count" \
'
{
name: $name,
server_flag: (.data.server_flag // false),
client_flag: (.data.client_flag // false),
allowed_domains: (.data.allowed_domains // []),
allow_subdomains: (.data.allow_subdomains // false),
allow_bare_domains: (.data.allow_bare_domains // false),
max_ttl: (.data.max_ttl // 0),
policy_refs: $refs,
policy_ref_count: $refcount
}' <<<"$JR" >>"$PKI_ND"
done
# USED zuerst (absteigend), dann FREE
USED_JSON="$(jq -s 'map(select(.policy_ref_count > 0)) | sort_by(-.policy_ref_count, .name)' "$PKI_ND")"
FREE_JSON="$(jq -s 'map(select(.policy_ref_count == 0)) | sort_by(.name)' "$PKI_ND")"
USED_CNT="$(jq -r 'length' <<<"$USED_JSON")"
FREE_CNT="$(jq -r 'length' <<<"$FREE_JSON")"
TOTAL=$(( USED_CNT + FREE_CNT ))
# USED
if (( USED_CNT > 0 )); then
echo " 🔗 USED (${USED_CNT}/${TOTAL}):"
jq -r '
.[] |
" 🔗 \(.name) [\(.policy_ref_count)] via: " + ((.policy_refs // []) | join(", ")) +
(
if ((.allowed_domains // []) | length) > 0
then "\n ↳ domains: " + ((.allowed_domains // []) | join(",")) +
" (subdomains=" + ((.allow_subdomains // false)|tostring) +
", bare=" + ((.allow_bare_domains // false)|tostring) + ")"
else ""
end
) +
"\n ↳ flags: server=" + ((.server_flag // false)|tostring) +
", client=" + ((.client_flag // false)|tostring) +
", max_ttl=" + ((.max_ttl // 0)|tostring)
' <<<"$USED_JSON"
fi
# FREE
if (( FREE_CNT > 0 )); then
echo " 🧹 FREE (${FREE_CNT}/${TOTAL}) — keine Policy-Referenzen:"
jq -r '
.[] |
" 🧹 " + .name + " (NO POLICY)" +
(
if ((.allowed_domains // []) | length) > 0
then "\n ↳ domains: " + ((.allowed_domains // []) | join(",")) +
" (subdomains=" + ((.allow_subdomains // false)|tostring) +
", bare=" + ((.allow_bare_domains // false)|tostring) + ")"
else ""
end
) +
"\n ↳ flags: server=" + ((.server_flag // false)|tostring) +
", client=" + ((.client_flag // false)|tostring) +
", max_ttl=" + ((.max_ttl // 0)|tostring)
' <<<"$FREE_JSON"
fi
# kompakte Summary
info "Policy-Ref-Analyse (${MOUNT}):"
echo " • total=${TOTAL}, used=${USED_CNT}, free=${FREE_CNT}"
if (( USED_CNT > 0 )); then
echo " • TOP USED:"
jq -r '.[] | " - \(.name) (\(.policy_ref_count)) -> " + ((.policy_refs // []) | join(", "))' <<<"$USED_JSON"
fi
else
echo " (keine Einträge)"
fi
else
warn "konnte PKI-Rollen nicht auflisten (${MOUNT}/roles)"
fi
# ===== Arrays für JSON/Enrichment (robust, auch wenn ND leer) =====
CERT_ARR="$(mk)"; ndjson_to_array "$CERT_ND" "$CERT_ARR"
APPROLE_ARR="$(mk)"; ndjson_to_array "$APPROLE_ND" "$APPROLE_ARR"
PKI_ARR="$(mk)"; ndjson_to_array "$PKI_ND" "$PKI_ARR"
POL_ARR="$(mk)"; ndjson_to_array "$POL_ND" "$POL_ARR"
# Policy-Namen (Set)
POL_NAMES_JSON="$(mk)"
printf '%s\n' "${POLS[@]:-}" | jq -R . | jq -s . > "$POL_NAMES_JSON"
# ===== Policies mit Reverse-Refs anreichern =====
POL_ENRICHED="$(mk)"
jq -n \
--slurpfile pol "$POL_ARR" \
--slurpfile cert "$CERT_ARR" \
--slurpfile ar "$APPROLE_ARR" \
'
($pol[0] // []) as $P
| ($cert[0] // []) as $C
| ($ar[0] // []) as $A
| [ foreach $P[] as $p ({};
{
name: $p.name,
paths: ($p.paths // []),
pki_issue_roles: ($p.pki_issue_roles // []),
wildcard_issue_sign: ($p.wildcard_issue_sign // false),
referenced_by: {
cert_mappings: (
$C | map(select((.policies // []) | index($p.name))) | map(.name)
),
approle_roles: (
$A | map(select((.token_policies // []) | index($p.name))) | map(.name)
)
}
}
) ]
' > "$POL_ENRICHED"
# ===== Missing policies (Cert/AppRole) =====
ALL_POLS="$(cat "$POL_NAMES_JSON")"
CERT_MISSING="$(mk)"
jq -n \
--slurpfile cert "$CERT_ARR" \
--argjson have "$ALL_POLS" '
($cert[0] // [])
| [ .[] as $c
| ($c.policies // []) as $ps
| [ $ps[] | select(($have | index(.)) == null) ] as $missing
| select(($missing | length) > 0)
| {name:$c.name, missing_policies:$missing}
]' > "$CERT_MISSING"
APPROLE_MISSING="$(mk)"
jq -n \
--slurpfile ar "$APPROLE_ARR" \
--argjson have "$ALL_POLS" '
($ar[0] // [])
| [ .[] as $a
| ($a.token_policies // []) as $ps
| [ $ps[] | select(($have | index(.)) == null) ] as $missing
| select(($missing | length) > 0)
| {name:$a.name, missing_policies:$missing}
]' > "$APPROLE_MISSING"
# ===== Graphviz DOT (optional visualization) =====
{
echo 'digraph vault_inventory {'
echo ' rankdir=LR;'
echo ' node [shape=box, style=rounded];'
echo ' subgraph cluster_cert { label="Cert-Auth Mappings"; style=dashed; color=gray;'
jq -r '.[].name | " \"" + . + "\""' "$CERT_ARR"
echo ' }'
echo ' subgraph cluster_ar { label="AppRole Roles"; style=dashed; color=gray;'
jq -r '.[].name | " \"" + . + "\""' "$APPROLE_ARR"
echo ' }'
echo ' subgraph cluster_pol { label="Policies"; style=dashed; color=gray;'
jq -r '.[].name | " \"" + . + "\""' "$POL_ENRICHED"
echo ' }'
echo ' subgraph cluster_pki { label="PKI Roles"; style=dashed; color=gray;'
jq -r '.[].name | " \"" + . + "\""' "$PKI_ARR"
echo ' }'
# edges: Cert -> Policy
jq -r '
.[] as $c
| ($c.policies // [])
| .[] as $p
| " \"" + $c.name + "\" -> \"" + $p + "\""
' "$CERT_ARR"
# edges: AppRole -> Policy
jq -r '
.[] as $a
| ($a.token_policies // [])
| .[] as $p
| " \"" + $a.name + "\" -> \"" + $p + "\""
' "$APPROLE_ARR"
# edges: Policy -> PKI role (via pki-test/(issue|sign)/<role>)
jq -r '
.[] as $p
| ($p.pki_issue_roles // [])
| .[] as $r
| " \"" + $p.name + "\" -> \"" + $r + "\""
' "$POL_ENRICHED"
echo '}'
} > "$OUT_DOT"
# ===== JSON-Report bauen (nutzt die *Array*-Dateien, nicht die ND-Files) =====
jq -n \
--arg generated "$(date -Iseconds)" \
--slurpfile cert "$CERT_ARR" \
--slurpfile approle "$APPROLE_ARR" \
--slurpfile pki "$PKI_ARR" \
--slurpfile policies "$POL_ENRICHED" \
--slurpfile cert_missing "$CERT_MISSING" \
--slurpfile approle_missing "$APPROLE_MISSING" \
'{
generated: $generated,
cert_mappings: ($cert[0] // []),
approle_roles: ($approle[0] // []),
pki_roles: ($pki[0] // []),
policies: ($policies[0] // []),
missing: {
cert_mappings: ($cert_missing[0] // []),
approle_roles: ($approle_missing[0] // [])
}
}' > "$OUT_JSON"
ok "Report geschrieben: ${OUT_JSON}"
echo " 🔎 Quick-Check:"
jq -r '[
"cert_mappings=\(.cert_mappings|length)",
"approle_roles=\(.approle_roles|length)",
"policies=\(.policies|length)",
"pki_roles=\(.pki_roles|length)",
("pki_roles_used=" + ((.pki_roles | map(select((.policy_ref_count // 0) > 0)) | length)|tostring)),
("pki_roles_free=" + ((.pki_roles | map(select((.policy_ref_count // 0) == 0)) | length)|tostring)),
("policies_refd_by_cert=" + ((.policies | map(select(.referenced_by.cert_mappings|length>0)) | length)|tostring)),
("policies_refd_by_approle=" + ((.policies | map(select(.referenced_by.approle_roles|length>0)) | length)|tostring))
] | " • " + (join(", "))' "$OUT_JSON"
echo " 🗺 DOT Graph: ${OUT_DOT} (optional: dot -Tpng \"$OUT_DOT\" -o /tmp/vault-inventory-${STAMP}.png)"