remediation-tool: add cert-auth (client_assertion JWT) to get-token.sh
Auth selection logic: - Default: prefer cert when cert_thumbprint_b64url + cert_private_key_pem_b64 are present in the vault entry's credentials block; fall back to client_secret. - REMEDIATION_AUTH=secret -> force client_secret flow. - REMEDIATION_AUTH=cert -> force cert flow; error if cert fields missing. - Logs [INFO] auth=cert/secret to stderr so users see which path was taken. Cert flow signs an RS256 JWT (header includes x5t) via inline Python (PyJWT + cryptography), POSTs client_assertion_type + client_assertion=<jwt> in place of client_secret. Same scope, same cache, same error handling (AADSTS7000229 still emits the consent URL). Single sops -d to a mktemp file feeds both field reads to avoid repeated ~1s decrypt invocations on Windows; trap removes plaintext on exit. Verified end-to-end against tedards.net for all three modes after wiping /tmp/remediation-tool/.
This commit is contained in:
@@ -10,6 +10,19 @@
|
|||||||
# tenant-admin ComputerGuru Tenant Admin -> Graph API (app roles, CA policy, directory write — high privilege)
|
# tenant-admin ComputerGuru Tenant Admin -> Graph API (app roles, CA policy, directory write — high privilege)
|
||||||
# defender ComputerGuru Defender Add-on -> Defender ATP API (MDE-licensed tenants only)
|
# defender ComputerGuru Defender Add-on -> Defender ATP API (MDE-licensed tenants only)
|
||||||
#
|
#
|
||||||
|
# Authentication: certificate-based (client_assertion JWT, RS256) is preferred
|
||||||
|
# when cert_thumbprint_b64url + cert_private_key_pem_b64 are present in the vault
|
||||||
|
# entry's credentials block. Falls back to client_secret otherwise.
|
||||||
|
#
|
||||||
|
# Override via env: REMEDIATION_AUTH=cert | secret (default: auto, prefer cert)
|
||||||
|
# - REMEDIATION_AUTH=cert forces cert; errors if cert fields missing (no fallback).
|
||||||
|
# - REMEDIATION_AUTH=secret forces client_secret; errors if secret missing.
|
||||||
|
# - unset / any other value: auto-detect (cert preferred, secret fallback).
|
||||||
|
#
|
||||||
|
# Cert fields read from the vault entry (under credentials:):
|
||||||
|
# cert_thumbprint_b64url x5t header value (base64url SHA1, no padding)
|
||||||
|
# cert_private_key_pem_b64 base64-encoded RSA private key PEM
|
||||||
|
#
|
||||||
# Output (stdout): bearer token. Exits 0 on success.
|
# Output (stdout): bearer token. Exits 0 on success.
|
||||||
# Cache: /tmp/remediation-tool/{tenant-id}/{tier}.jwt TTL 55 minutes.
|
# Cache: /tmp/remediation-tool/{tenant-id}/{tier}.jwt TTL 55 minutes.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -94,11 +107,9 @@ IDENTITY_FILE="$CLAUDETOOLS_ROOT/.claude/identity.json"
|
|||||||
|
|
||||||
VAULT_ROOT="${VAULT_ROOT_ENV:-}"
|
VAULT_ROOT="${VAULT_ROOT_ENV:-}"
|
||||||
if [[ -z "$VAULT_ROOT" && -f "$IDENTITY_FILE" ]]; then
|
if [[ -z "$VAULT_ROOT" && -f "$IDENTITY_FILE" ]]; then
|
||||||
# Try jq first (handles Unix paths on Windows cleanly)
|
|
||||||
if command -v jq >/dev/null 2>&1; then
|
if command -v jq >/dev/null 2>&1; then
|
||||||
VAULT_ROOT=$(jq -r '.vault_path // empty' "$IDENTITY_FILE" 2>/dev/null)
|
VAULT_ROOT=$(jq -r '.vault_path // empty' "$IDENTITY_FILE" 2>/dev/null)
|
||||||
fi
|
fi
|
||||||
# Fall back to Python with Windows path conversion
|
|
||||||
if [[ -z "$VAULT_ROOT" ]]; then
|
if [[ -z "$VAULT_ROOT" ]]; then
|
||||||
IDENTITY_FILE_WIN=$(cygpath -w "$IDENTITY_FILE" 2>/dev/null || echo "$IDENTITY_FILE")
|
IDENTITY_FILE_WIN=$(cygpath -w "$IDENTITY_FILE" 2>/dev/null || echo "$IDENTITY_FILE")
|
||||||
for py in py python3 python; do
|
for py in py python3 python; do
|
||||||
@@ -114,48 +125,224 @@ fi
|
|||||||
SOPS_FILE="$VAULT_ROOT/$VAULT_PATH"
|
SOPS_FILE="$VAULT_ROOT/$VAULT_PATH"
|
||||||
[[ ! -f "$SOPS_FILE" ]] && { echo "ERROR: vault file not found: $SOPS_FILE" >&2; exit 3; }
|
[[ ! -f "$SOPS_FILE" ]] && { echo "ERROR: vault file not found: $SOPS_FILE" >&2; exit 3; }
|
||||||
|
|
||||||
# Read client secret via vault.sh (fast path), falling back to raw sops+python
|
# Pick a Python interpreter once for all helpers.
|
||||||
CLIENT_SECRET=""
|
PYTHON_BIN=""
|
||||||
|
for p in py python python3; do command -v "$p" >/dev/null 2>&1 && PYTHON_BIN="$p" && break; done
|
||||||
|
|
||||||
|
# Decrypt the SOPS file once and cache plaintext in a memory-mapped tmp file.
|
||||||
|
# All field reads below pull from this single decrypt; saves multiple sops invocations
|
||||||
|
# (each is ~1s on Windows due to age key load) and avoids piping ~2KB key material
|
||||||
|
# through subshells repeatedly.
|
||||||
|
PLAINTEXT_FILE="$(mktemp -t sops-plain.XXXXXX)"
|
||||||
|
trap 'rm -f "$PLAINTEXT_FILE"' EXIT
|
||||||
|
|
||||||
|
# Try vault.sh first (handles yq vs python-yaml selection internally).
|
||||||
|
# If that fails (e.g. PyYAML missing on this host), do a raw sops decrypt.
|
||||||
|
SOPS_OK=0
|
||||||
if [[ -f "$VAULT_ROOT/scripts/vault.sh" ]]; then
|
if [[ -f "$VAULT_ROOT/scripts/vault.sh" ]]; then
|
||||||
# Try both field names — new files use client_secret, legacy used credential
|
if bash "$VAULT_ROOT/scripts/vault.sh" get "$VAULT_PATH" > "$PLAINTEXT_FILE" 2>/dev/null && [[ -s "$PLAINTEXT_FILE" ]]; then
|
||||||
CLIENT_SECRET=$(bash "$VAULT_ROOT/scripts/vault.sh" get-field "$VAULT_PATH" credentials.client_secret 2>/dev/null | tr -d '\r\n' || true)
|
SOPS_OK=1
|
||||||
if [[ -z "$CLIENT_SECRET" ]]; then
|
fi
|
||||||
CLIENT_SECRET=$(bash "$VAULT_ROOT/scripts/vault.sh" get-field "$VAULT_PATH" credentials.credential 2>/dev/null | tr -d '\r\n' || true)
|
fi
|
||||||
|
if [[ $SOPS_OK -eq 0 ]]; then
|
||||||
|
command -v sops >/dev/null 2>&1 || { echo "ERROR: vault.sh failed and sops not on PATH (needed for fallback decrypt)" >&2; exit 3; }
|
||||||
|
if ! sops -d "$SOPS_FILE" > "$PLAINTEXT_FILE" 2>/dev/null; then
|
||||||
|
echo "ERROR: sops decrypt failed for $SOPS_FILE" >&2
|
||||||
|
exit 3
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "$CLIENT_SECRET" ]]; then
|
# read_field <field-name> -> stdout (empty if missing)
|
||||||
PYTHON_BIN=""
|
# Tries PyYAML for correctness; falls back to a tolerant flat-key regex that handles
|
||||||
for p in py python python3; do command -v "$p" >/dev/null 2>&1 && PYTHON_BIN="$p" && break; done
|
# the actual vault file shape (single-line scalars under a 4-space-indented credentials:
|
||||||
[[ -z "$PYTHON_BIN" ]] && { echo "ERROR: vault.sh failed and python unavailable for SOPS fallback" >&2; exit 3; }
|
# block — including 2KB+ values like cert_private_key_pem_b64).
|
||||||
command -v sops >/dev/null 2>&1 || { echo "ERROR: sops not on PATH (needed for fallback decrypt)" >&2; exit 3; }
|
read_field() {
|
||||||
|
local field="$1"
|
||||||
CLIENT_SECRET=$(sops -d "$SOPS_FILE" 2>/dev/null | "$PYTHON_BIN" -c "
|
local val=""
|
||||||
|
if [[ -n "$PYTHON_BIN" ]]; then
|
||||||
|
val=$("$PYTHON_BIN" - "$PLAINTEXT_FILE" "$field" <<'PY' 2>/dev/null || true
|
||||||
|
import sys
|
||||||
|
path, field = sys.argv[1], sys.argv[2]
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError:
|
||||||
|
sys.exit(1)
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
doc = yaml.safe_load(f) or {}
|
||||||
|
except Exception:
|
||||||
|
sys.exit(1)
|
||||||
|
creds = (doc.get("credentials") or {})
|
||||||
|
v = creds.get(field)
|
||||||
|
if v is None:
|
||||||
|
sys.exit(0)
|
||||||
|
print(v)
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
val="${val%$'\r'}"
|
||||||
|
if [[ -n "$val" ]]; then
|
||||||
|
printf '%s' "$val"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
# Regex fallback: flat keys under credentials:, indent 2 or 4 spaces, value on same line.
|
||||||
|
# Strips surrounding quotes if present. Stops at first match. Works for the actual
|
||||||
|
# vault format because cert_private_key_pem_b64 is emitted as a single long line.
|
||||||
|
if [[ -n "$PYTHON_BIN" ]]; then
|
||||||
|
val=$("$PYTHON_BIN" - "$PLAINTEXT_FILE" "$field" <<'PY' 2>/dev/null || true
|
||||||
import sys, re
|
import sys, re
|
||||||
t = sys.stdin.read()
|
path, field = sys.argv[1], sys.argv[2]
|
||||||
m = re.search(r'^credentials:\s*\n((?:[ \t]+.*\n)+)', t, re.MULTILINE)
|
try:
|
||||||
if not m: sys.exit(1)
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
for line in m.group(1).splitlines():
|
text = f.read()
|
||||||
line = line.strip()
|
except Exception:
|
||||||
if line.startswith('client_secret:') or line.startswith('credential:'):
|
sys.exit(1)
|
||||||
print(line.split(':', 1)[1].strip().strip('\"').strip(\"'\"))
|
m = re.search(r'(?ms)^credentials:\s*\n((?:[ \t]+.*\n?)+)', text)
|
||||||
break
|
if not m:
|
||||||
" | tr -d '\r\n')
|
sys.exit(0)
|
||||||
fi
|
block = m.group(1)
|
||||||
|
pat = re.compile(r'^[ \t]+' + re.escape(field) + r'\s*:\s*(.*?)\s*$', re.MULTILINE)
|
||||||
[[ -z "$CLIENT_SECRET" ]] && {
|
fm = pat.search(block)
|
||||||
echo "ERROR: could not read secret from $VAULT_PATH" >&2
|
if not fm:
|
||||||
echo " Check field: credentials.client_secret (or credentials.credential for older entries)" >&2
|
sys.exit(0)
|
||||||
exit 4
|
v = fm.group(1)
|
||||||
|
if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")):
|
||||||
|
v = v[1:-1]
|
||||||
|
print(v)
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
val="${val%$'\r'}"
|
||||||
|
printf '%s' "$val"
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Request token
|
# Auth-method selection: env override > auto-detect.
|
||||||
RESP=$(curl -s --max-time 15 -X POST \
|
AUTH_OVERRIDE="${REMEDIATION_AUTH:-}"
|
||||||
"https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token" \
|
AUTH_METHOD=""
|
||||||
--data-urlencode "client_id=${CLIENT_ID}" \
|
CERT_X5T=""
|
||||||
--data-urlencode "client_secret=${CLIENT_SECRET}" \
|
CERT_KEY_B64=""
|
||||||
--data-urlencode "scope=${SCOPE_URL}" \
|
CLIENT_SECRET=""
|
||||||
--data-urlencode "grant_type=client_credentials")
|
|
||||||
|
case "$AUTH_OVERRIDE" in
|
||||||
|
cert)
|
||||||
|
CERT_X5T=$(read_field cert_thumbprint_b64url | tr -d '\r\n')
|
||||||
|
CERT_KEY_B64=$(read_field cert_private_key_pem_b64 | tr -d '\r\n')
|
||||||
|
if [[ -z "$CERT_X5T" || -z "$CERT_KEY_B64" ]]; then
|
||||||
|
echo "ERROR: REMEDIATION_AUTH=cert but cert fields missing in vault ($VAULT_PATH)" >&2
|
||||||
|
echo " Required fields under credentials: cert_thumbprint_b64url, cert_private_key_pem_b64" >&2
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
AUTH_METHOD="cert"
|
||||||
|
;;
|
||||||
|
secret)
|
||||||
|
CLIENT_SECRET=$(read_field client_secret | tr -d '\r\n')
|
||||||
|
if [[ -z "$CLIENT_SECRET" ]]; then
|
||||||
|
CLIENT_SECRET=$(read_field credential | tr -d '\r\n')
|
||||||
|
fi
|
||||||
|
if [[ -z "$CLIENT_SECRET" ]]; then
|
||||||
|
echo "ERROR: REMEDIATION_AUTH=secret but client_secret missing in vault ($VAULT_PATH)" >&2
|
||||||
|
echo " Check field: credentials.client_secret (or credentials.credential for older entries)" >&2
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
AUTH_METHOD="secret"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
CERT_X5T=$(read_field cert_thumbprint_b64url | tr -d '\r\n')
|
||||||
|
CERT_KEY_B64=$(read_field cert_private_key_pem_b64 | tr -d '\r\n')
|
||||||
|
if [[ -n "$CERT_X5T" && -n "$CERT_KEY_B64" ]]; then
|
||||||
|
AUTH_METHOD="cert"
|
||||||
|
else
|
||||||
|
CLIENT_SECRET=$(read_field client_secret | tr -d '\r\n')
|
||||||
|
if [[ -z "$CLIENT_SECRET" ]]; then
|
||||||
|
CLIENT_SECRET=$(read_field credential | tr -d '\r\n')
|
||||||
|
fi
|
||||||
|
if [[ -z "$CLIENT_SECRET" ]]; then
|
||||||
|
echo "ERROR: no usable credential found in $VAULT_PATH" >&2
|
||||||
|
echo " Need either credentials.cert_thumbprint_b64url + credentials.cert_private_key_pem_b64," >&2
|
||||||
|
echo " or credentials.client_secret (or legacy credentials.credential)." >&2
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
AUTH_METHOD="secret"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "[INFO] auth=$AUTH_METHOD" >&2
|
||||||
|
|
||||||
|
# Build request and POST.
|
||||||
|
TOKEN_URL="https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token"
|
||||||
|
|
||||||
|
if [[ "$AUTH_METHOD" == "cert" ]]; then
|
||||||
|
[[ -z "$PYTHON_BIN" ]] && { echo "ERROR: cert auth requires python (py/python/python3) for JWT signing" >&2; exit 4; }
|
||||||
|
|
||||||
|
CLIENT_ASSERTION=$(CERT_X5T_ENV="$CERT_X5T" \
|
||||||
|
CERT_KEY_B64_ENV="$CERT_KEY_B64" \
|
||||||
|
CLIENT_ID_ENV="$CLIENT_ID" \
|
||||||
|
TOKEN_URL_ENV="$TOKEN_URL" \
|
||||||
|
"$PYTHON_BIN" - <<'PY' 2>&1
|
||||||
|
import os, sys, time, uuid, base64
|
||||||
|
try:
|
||||||
|
import jwt
|
||||||
|
except ImportError:
|
||||||
|
sys.stderr.write("ERROR: PyJWT not installed (pip install PyJWT cryptography)\n")
|
||||||
|
sys.exit(2)
|
||||||
|
try:
|
||||||
|
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||||
|
except ImportError:
|
||||||
|
sys.stderr.write("ERROR: cryptography not installed (pip install cryptography)\n")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
x5t = os.environ["CERT_X5T_ENV"]
|
||||||
|
key_b64 = os.environ["CERT_KEY_B64_ENV"]
|
||||||
|
client_id = os.environ["CLIENT_ID_ENV"]
|
||||||
|
aud = os.environ["TOKEN_URL_ENV"]
|
||||||
|
|
||||||
|
try:
|
||||||
|
key_pem = base64.b64decode(key_b64)
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write(f"ERROR: cert_private_key_pem_b64 is not valid base64: {e}\n")
|
||||||
|
sys.exit(3)
|
||||||
|
|
||||||
|
# Validate the PEM parses before handing to PyJWT, for a clearer error.
|
||||||
|
try:
|
||||||
|
load_pem_private_key(key_pem, password=None)
|
||||||
|
except Exception as e:
|
||||||
|
sys.stderr.write(f"ERROR: cert_private_key_pem_b64 did not decode to a valid PEM private key: {e}\n")
|
||||||
|
sys.exit(3)
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
payload = {
|
||||||
|
"aud": aud,
|
||||||
|
"iss": client_id,
|
||||||
|
"sub": client_id,
|
||||||
|
"jti": str(uuid.uuid4()),
|
||||||
|
"exp": now + 300,
|
||||||
|
"nbf": now,
|
||||||
|
}
|
||||||
|
token = jwt.encode(payload, key_pem, algorithm="RS256", headers={"x5t": x5t})
|
||||||
|
sys.stdout.write(token)
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
ASSERT_RC=$?
|
||||||
|
if [[ $ASSERT_RC -ne 0 || -z "$CLIENT_ASSERTION" ]]; then
|
||||||
|
echo "ERROR: failed to build client_assertion JWT" >&2
|
||||||
|
[[ -n "$CLIENT_ASSERTION" ]] && echo "$CLIENT_ASSERTION" >&2
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
|
||||||
|
RESP=$(curl -s --max-time 15 -X POST "$TOKEN_URL" \
|
||||||
|
--data-urlencode "client_id=${CLIENT_ID}" \
|
||||||
|
--data-urlencode "client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer" \
|
||||||
|
--data-urlencode "client_assertion=${CLIENT_ASSERTION}" \
|
||||||
|
--data-urlencode "scope=${SCOPE_URL}" \
|
||||||
|
--data-urlencode "grant_type=client_credentials")
|
||||||
|
else
|
||||||
|
RESP=$(curl -s --max-time 15 -X POST "$TOKEN_URL" \
|
||||||
|
--data-urlencode "client_id=${CLIENT_ID}" \
|
||||||
|
--data-urlencode "client_secret=${CLIENT_SECRET}" \
|
||||||
|
--data-urlencode "scope=${SCOPE_URL}" \
|
||||||
|
--data-urlencode "grant_type=client_credentials")
|
||||||
|
fi
|
||||||
|
|
||||||
TOKEN=$(echo "$RESP" | jq -r '.access_token // empty')
|
TOKEN=$(echo "$RESP" | jq -r '.access_token // empty')
|
||||||
|
|
||||||
@@ -165,7 +352,7 @@ if [[ -z "$TOKEN" ]]; then
|
|||||||
|
|
||||||
# AADSTS7000229 — service principal not found in tenant (not consented)
|
# AADSTS7000229 — service principal not found in tenant (not consented)
|
||||||
if echo "$ERROR_DESC" | grep -qi "7000229\|AADSTS7000229" || [[ "$ERROR_CODE" == "7000229" ]]; then
|
if echo "$ERROR_DESC" | grep -qi "7000229\|AADSTS7000229" || [[ "$ERROR_CODE" == "7000229" ]]; then
|
||||||
echo "ERROR: AADSTS7000229 — app not consented in tenant $TENANT_ID (tier=$TIER)" >&2
|
echo "ERROR: AADSTS7000229 — app not consented in tenant $TENANT_ID (tier=$TIER, auth=$AUTH_METHOD)" >&2
|
||||||
echo "" >&2
|
echo "" >&2
|
||||||
echo " The '${TIER}' service principal has not been authorized in this tenant." >&2
|
echo " The '${TIER}' service principal has not been authorized in this tenant." >&2
|
||||||
echo " Send this consent URL to the customer Global Admin:" >&2
|
echo " Send this consent URL to the customer Global Admin:" >&2
|
||||||
@@ -178,7 +365,7 @@ if [[ -z "$TOKEN" ]]; then
|
|||||||
exit 5
|
exit 5
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "ERROR: token request failed (tenant=$TENANT_ID tier=$TIER)" >&2
|
echo "ERROR: token request failed (tenant=$TENANT_ID tier=$TIER auth=$AUTH_METHOD)" >&2
|
||||||
echo "$RESP" >&2
|
echo "$RESP" >&2
|
||||||
exit 5
|
exit 5
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user