From a92d6f64806ce0d2eda22da9d977a0eddc13e1e3 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 22 Jun 2026 19:44:35 -0400 Subject: [PATCH] feat(security-review): confluence-doc supports OAuth 2.0 client-credentials (service account) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atlassian org service accounts have no classic API token — they authenticate via OAuth 2.0 client-credentials (2LO). Add a dual-mode auth seam to confluence-doc: - OAuth (preferred when CONFLUENCE_OAUTH_CLIENT_ID/_SECRET set): POST auth.atlassian.com/oauth/token (client_id+client_secret+grant_type=client_credentials) → 60-min Bearer; calls go to api.atlassian.com/ex/confluence//wiki/api/v2/... cloudId auto-resolves from the site's public /_edge/tenant_info (no input needed). - Basic (email+API token) retained as a fallback. conf_api_init() picks the mode once; conf_get() does the authenticated GET. Any failure (no cloudId / token request fails) → RUN_API=0, live checks SKIPPED, NO false alarm (matches the existing no-data discipline). Secret passed in the request body (--data-urlencode), never logged. Still read + recommend-only (D7); canary unaffected (offline) — 3/3. shellcheck clean (accepted SC1091). --- security-review/checkers/confluence-doc.sh | 82 +++++++++++++++++++--- 1 file changed, 74 insertions(+), 8 deletions(-) diff --git a/security-review/checkers/confluence-doc.sh b/security-review/checkers/confluence-doc.sh index cdc8ac8..35107dc 100755 --- a/security-review/checkers/confluence-doc.sh +++ b/security-review/checkers/confluence-doc.sh @@ -85,9 +85,23 @@ PAGE_MAP_FILE="${PAGE_MAP_FILE:-}" # Optional read-only AWS inventory JSON (stacks/Lambdas) — absent => that check is SKIPPED. AWS_INVENTORY_FILE="${AWS_INVENTORY_FILE:-}" # Confluence Cloud REST (the confluence-bot creds, D6) — absent => API checks SKIPPED. +# TWO auth modes are supported; OAuth takes precedence when its creds are present: +# (A) OAuth 2.0 client-credentials (2LO) for an org SERVICE ACCOUNT (preferred for a +# headless bot — Atlassian org service accounts have no classic API token): +# POST https://auth.atlassian.com/oauth/token (client_id+client_secret+ +# grant_type=client_credentials) -> 60-min Bearer token, then call +# https://api.atlassian.com/ex/confluence//wiki/api/v2/... +# (B) Basic auth (account email + API token) against the site /wiki/api/v2/... CONFLUENCE_BASE_URL="${CONFLUENCE_BASE_URL:-}" CONFLUENCE_EMAIL="${CONFLUENCE_EMAIL:-}" CONFLUENCE_API_TOKEN="${CONFLUENCE_API_TOKEN:-}" +CONFLUENCE_OAUTH_CLIENT_ID="${CONFLUENCE_OAUTH_CLIENT_ID:-}" +CONFLUENCE_OAUTH_CLIENT_SECRET="${CONFLUENCE_OAUTH_CLIENT_SECRET:-}" +# Optional: the site cloudId. If empty under OAuth, it is auto-resolved from the +# site's public /_edge/tenant_info (no auth needed). +CONFLUENCE_CLOUD_ID="${CONFLUENCE_CLOUD_ID:-}" +# Atlassian OAuth token endpoint (overridable only for testing). +CONFLUENCE_OAUTH_TOKEN_URL="${CONFLUENCE_OAUTH_TOKEN_URL:-https://auth.atlassian.com/oauth/token}" # A mapped page is "stale" if its lastUpdated is older than this many days (API check only). STALE_DAYS="${STALE_DAYS:-180}" # Repos exempt from needing their own IT page (mirrors compliance-drift's exemption style). @@ -195,6 +209,60 @@ map_has_exact_key() { # exact page title # ============================================================================== # CONFLUENCE API (LIVE reads; need the confluence-bot creds; skipped offline/--no-api/--canary) +# ============================================================================== +# Confluence auth seam: OAuth 2.0 client-credentials (org service account, 2LO) OR +# Basic auth (email + API token). conf_api_init() resolves ONE mode (fetching a +# 60-min Bearer + the cloudId for OAuth); conf_get() does the authenticated GET +# with the right base + header. OAuth wins when its creds are present. Any +# failure (no cloudId, token request fails) returns non-zero so the caller SKIPS +# the live checks — never a false alarm on missing data. +# ============================================================================== +_CONF_MODE=""; _CONF_BASE=""; _CONF_BEARER="" + +conf_api_init() { + if [ -n "$CONFLUENCE_OAUTH_CLIENT_ID" ] && [ -n "$CONFLUENCE_OAUTH_CLIENT_SECRET" ]; then + local cid="$CONFLUENCE_CLOUD_ID" + if [ -z "$cid" ] && [ -n "$CONFLUENCE_BASE_URL" ]; then + cid="$(curl -sS -H 'Accept: application/json' \ + "$CONFLUENCE_BASE_URL/_edge/tenant_info" 2>>"$REPORT_DIR/confluence-api.log" \ + | jq -r '.cloudId // empty' 2>/dev/null)" + fi + [ -n "$cid" ] || { log "OAuth: could not resolve cloudId — skipping API"; return 1; } + # 2LO client-credentials token. The secret goes in the request BODY via + # --data-urlencode and is never echoed/logged (matches the existing -u risk class). + local tok + tok="$(curl -sS -X POST "$CONFLUENCE_OAUTH_TOKEN_URL" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode "client_id=$CONFLUENCE_OAUTH_CLIENT_ID" \ + --data-urlencode "client_secret=$CONFLUENCE_OAUTH_CLIENT_SECRET" \ + --data-urlencode 'grant_type=client_credentials' \ + 2>>"$REPORT_DIR/confluence-api.log" | jq -r '.access_token // empty' 2>/dev/null)" + [ -n "$tok" ] || { log "OAuth: token request failed — skipping API (no false alarm)"; return 1; } + _CONF_MODE="oauth"; _CONF_BEARER="$tok" + _CONF_BASE="https://api.atlassian.com/ex/confluence/$cid" + return 0 + fi + if [ -n "$CONFLUENCE_BASE_URL" ] && [ -n "$CONFLUENCE_EMAIL" ] \ + && [ -n "$CONFLUENCE_API_TOKEN" ]; then + _CONF_MODE="basic"; _CONF_BASE="$CONFLUENCE_BASE_URL" + return 0 + fi + return 1 +} + +conf_get() { # path_suffix outfile -> echoes http_code (both modes share /wiki/api/v2/...) + local path="$1" out="$2" + if [ "$_CONF_MODE" = "oauth" ]; then + curl -sS -o "$out" -w '%{http_code}' \ + -H "Authorization: Bearer $_CONF_BEARER" -H 'Accept: application/json' \ + "$_CONF_BASE$path" 2>>"$REPORT_DIR/confluence-api.log" || echo 000 + else + curl -sS -o "$out" -w '%{http_code}' \ + -u "$CONFLUENCE_EMAIL:$CONFLUENCE_API_TOKEN" -H 'Accept: application/json' \ + "$_CONF_BASE$path" 2>>"$REPORT_DIR/confluence-api.log" || echo 000 + fi +} + # ============================================================================== # Confirm a mapped page still exists and is not stale. Status-code-aware, mirroring # compliance-drift's branch-protection pattern exactly: @@ -205,11 +273,7 @@ conf_check_page() { # page_title page_id local title="$1" pid="$2" local tmp code body tmp="$(mktemp)" - code="$(curl -sS -o "$tmp" -w '%{http_code}' \ - -u "$CONFLUENCE_EMAIL:$CONFLUENCE_API_TOKEN" \ - -H 'Accept: application/json' \ - "$CONFLUENCE_BASE_URL/wiki/api/v2/pages/$pid?body-format=storage" \ - 2>>"$REPORT_DIR/confluence-api.log" || echo 000)" + code="$(conf_get "/wiki/api/v2/pages/$pid?body-format=storage" "$tmp")" body="$(cat "$tmp" 2>/dev/null)"; rm -f "$tmp" case "$code" in 200) @@ -290,11 +354,13 @@ fi [ -f "$PAGE_MAP_FILE" ] || die "page-ID map not found: $PAGE_MAP_FILE" jq -e 'type=="object"' "$PAGE_MAP_FILE" >/dev/null 2>&1 || die "page-ID map is not a JSON object: $PAGE_MAP_FILE" -# Decide whether the LIVE Confluence API runs: need creds, API enabled, curl, not offline canary. +# Decide whether the LIVE Confluence API runs: need curl, API enabled, not offline +# canary, AND an auth mode that initializes (OAuth service account or Basic). A +# token/cloudId failure leaves RUN_API=0 → checks skipped, NO false alarm. RUN_API=0 -if [ "$DO_API" -eq 1 ] && [ -n "$CONFLUENCE_BASE_URL" ] && [ -n "$CONFLUENCE_EMAIL" ] \ - && [ -n "$CONFLUENCE_API_TOKEN" ] && command -v curl >/dev/null; then +if [ "$DO_API" -eq 1 ] && command -v curl >/dev/null && conf_api_init; then RUN_API=1 + log "Confluence API: ${_CONF_MODE} auth ready" elif [ "$DO_API" -eq 1 ]; then log "Confluence API requested but confluence-bot creds/curl unavailable — skipping live checks (no false alarms on missing data; the service account is gated provisioning)." fi