diff --git a/.claude/skills/remediation-tool/scripts/get-token.sh b/.claude/skills/remediation-tool/scripts/get-token.sh index 8f6f206..d2eb7bd 100755 --- a/.claude/skills/remediation-tool/scripts/get-token.sh +++ b/.claude/skills/remediation-tool/scripts/get-token.sh @@ -10,6 +10,19 @@ # 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) # +# 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. # Cache: /tmp/remediation-tool/{tenant-id}/{tier}.jwt TTL 55 minutes. set -euo pipefail @@ -94,11 +107,9 @@ IDENTITY_FILE="$CLAUDETOOLS_ROOT/.claude/identity.json" VAULT_ROOT="${VAULT_ROOT_ENV:-}" 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 VAULT_ROOT=$(jq -r '.vault_path // empty' "$IDENTITY_FILE" 2>/dev/null) fi - # Fall back to Python with Windows path conversion if [[ -z "$VAULT_ROOT" ]]; then IDENTITY_FILE_WIN=$(cygpath -w "$IDENTITY_FILE" 2>/dev/null || echo "$IDENTITY_FILE") for py in py python3 python; do @@ -114,48 +125,224 @@ fi SOPS_FILE="$VAULT_ROOT/$VAULT_PATH" [[ ! -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 -CLIENT_SECRET="" +# Pick a Python interpreter once for all helpers. +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 - # Try both field names — new files use client_secret, legacy used credential - CLIENT_SECRET=$(bash "$VAULT_ROOT/scripts/vault.sh" get-field "$VAULT_PATH" credentials.client_secret 2>/dev/null | tr -d '\r\n' || true) - if [[ -z "$CLIENT_SECRET" ]]; then - CLIENT_SECRET=$(bash "$VAULT_ROOT/scripts/vault.sh" get-field "$VAULT_PATH" credentials.credential 2>/dev/null | tr -d '\r\n' || true) + if bash "$VAULT_ROOT/scripts/vault.sh" get "$VAULT_PATH" > "$PLAINTEXT_FILE" 2>/dev/null && [[ -s "$PLAINTEXT_FILE" ]]; then + SOPS_OK=1 + fi +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 -if [[ -z "$CLIENT_SECRET" ]]; then - PYTHON_BIN="" - for p in py python python3; do command -v "$p" >/dev/null 2>&1 && PYTHON_BIN="$p" && break; done - [[ -z "$PYTHON_BIN" ]] && { echo "ERROR: vault.sh failed and python unavailable for SOPS fallback" >&2; exit 3; } - command -v sops >/dev/null 2>&1 || { echo "ERROR: sops not on PATH (needed for fallback decrypt)" >&2; exit 3; } - - CLIENT_SECRET=$(sops -d "$SOPS_FILE" 2>/dev/null | "$PYTHON_BIN" -c " +# read_field -> stdout (empty if missing) +# Tries PyYAML for correctness; falls back to a tolerant flat-key regex that handles +# the actual vault file shape (single-line scalars under a 4-space-indented credentials: +# block — including 2KB+ values like cert_private_key_pem_b64). +read_field() { + local field="$1" + 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 -t = sys.stdin.read() -m = re.search(r'^credentials:\s*\n((?:[ \t]+.*\n)+)', t, re.MULTILINE) -if not m: sys.exit(1) -for line in m.group(1).splitlines(): - line = line.strip() - if line.startswith('client_secret:') or line.startswith('credential:'): - print(line.split(':', 1)[1].strip().strip('\"').strip(\"'\")) - break -" | tr -d '\r\n') -fi - -[[ -z "$CLIENT_SECRET" ]] && { - echo "ERROR: could not read secret from $VAULT_PATH" >&2 - echo " Check field: credentials.client_secret (or credentials.credential for older entries)" >&2 - exit 4 +path, field = sys.argv[1], sys.argv[2] +try: + with open(path, "r", encoding="utf-8") as f: + text = f.read() +except Exception: + sys.exit(1) +m = re.search(r'(?ms)^credentials:\s*\n((?:[ \t]+.*\n?)+)', text) +if not m: + sys.exit(0) +block = m.group(1) +pat = re.compile(r'^[ \t]+' + re.escape(field) + r'\s*:\s*(.*?)\s*$', re.MULTILINE) +fm = pat.search(block) +if not fm: + sys.exit(0) +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 -RESP=$(curl -s --max-time 15 -X POST \ - "https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token" \ - --data-urlencode "client_id=${CLIENT_ID}" \ - --data-urlencode "client_secret=${CLIENT_SECRET}" \ - --data-urlencode "scope=${SCOPE_URL}" \ - --data-urlencode "grant_type=client_credentials") +# Auth-method selection: env override > auto-detect. +AUTH_OVERRIDE="${REMEDIATION_AUTH:-}" +AUTH_METHOD="" +CERT_X5T="" +CERT_KEY_B64="" +CLIENT_SECRET="" + +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') @@ -165,7 +352,7 @@ if [[ -z "$TOKEN" ]]; then # AADSTS7000229 — service principal not found in tenant (not consented) 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 " The '${TIER}' service principal has not been authorized in this tenant." >&2 echo " Send this consent URL to the customer Global Admin:" >&2 @@ -178,7 +365,7 @@ if [[ -z "$TOKEN" ]]; then exit 5 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 exit 5 fi