feat(security-review): confluence-doc supports OAuth 2.0 client-credentials (service account) (#40)

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/<cloudId>/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).
This commit is contained in:
Adam Moussa 2026-06-22 19:45:42 -04:00 • committed by GitHub
parent 0a5a78ecf3
commit 332652257b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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/<cloudId>/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