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:
parent
0a5a78ecf3
commit
332652257b
1 changed files with 74 additions and 8 deletions
|
|
@ -85,9 +85,23 @@ PAGE_MAP_FILE="${PAGE_MAP_FILE:-}"
|
||||||
# Optional read-only AWS inventory JSON (stacks/Lambdas) — absent => that check is SKIPPED.
|
# Optional read-only AWS inventory JSON (stacks/Lambdas) — absent => that check is SKIPPED.
|
||||||
AWS_INVENTORY_FILE="${AWS_INVENTORY_FILE:-}"
|
AWS_INVENTORY_FILE="${AWS_INVENTORY_FILE:-}"
|
||||||
# Confluence Cloud REST (the confluence-bot creds, D6) — absent => API checks SKIPPED.
|
# 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_BASE_URL="${CONFLUENCE_BASE_URL:-}"
|
||||||
CONFLUENCE_EMAIL="${CONFLUENCE_EMAIL:-}"
|
CONFLUENCE_EMAIL="${CONFLUENCE_EMAIL:-}"
|
||||||
CONFLUENCE_API_TOKEN="${CONFLUENCE_API_TOKEN:-}"
|
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).
|
# A mapped page is "stale" if its lastUpdated is older than this many days (API check only).
|
||||||
STALE_DAYS="${STALE_DAYS:-180}"
|
STALE_DAYS="${STALE_DAYS:-180}"
|
||||||
# Repos exempt from needing their own IT page (mirrors compliance-drift's exemption style).
|
# 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 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
|
# Confirm a mapped page still exists and is not stale. Status-code-aware, mirroring
|
||||||
# compliance-drift's branch-protection pattern exactly:
|
# compliance-drift's branch-protection pattern exactly:
|
||||||
|
|
@ -205,11 +273,7 @@ conf_check_page() { # page_title page_id
|
||||||
local title="$1" pid="$2"
|
local title="$1" pid="$2"
|
||||||
local tmp code body
|
local tmp code body
|
||||||
tmp="$(mktemp)"
|
tmp="$(mktemp)"
|
||||||
code="$(curl -sS -o "$tmp" -w '%{http_code}' \
|
code="$(conf_get "/wiki/api/v2/pages/$pid?body-format=storage" "$tmp")"
|
||||||
-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)"
|
|
||||||
body="$(cat "$tmp" 2>/dev/null)"; rm -f "$tmp"
|
body="$(cat "$tmp" 2>/dev/null)"; rm -f "$tmp"
|
||||||
case "$code" in
|
case "$code" in
|
||||||
200)
|
200)
|
||||||
|
|
@ -290,11 +354,13 @@ fi
|
||||||
[ -f "$PAGE_MAP_FILE" ] || die "page-ID map not found: $PAGE_MAP_FILE"
|
[ -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"
|
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
|
RUN_API=0
|
||||||
if [ "$DO_API" -eq 1 ] && [ -n "$CONFLUENCE_BASE_URL" ] && [ -n "$CONFLUENCE_EMAIL" ] \
|
if [ "$DO_API" -eq 1 ] && command -v curl >/dev/null && conf_api_init; then
|
||||||
&& [ -n "$CONFLUENCE_API_TOKEN" ] && command -v curl >/dev/null; then
|
|
||||||
RUN_API=1
|
RUN_API=1
|
||||||
|
log "Confluence API: ${_CONF_MODE} auth ready"
|
||||||
elif [ "$DO_API" -eq 1 ]; then
|
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)."
|
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
|
fi
|
||||||
|
|
|
||||||
Reference in a new issue