Files
T

255 lines
9.0 KiB
Python

#!/usr/bin/env python3
"""Okta SCIM provisioning audit agent.
Audits Okta SCIM provisioning configuration by querying the Okta API
for provisioned applications, user assignments, group memberships, and
deprovisioning status. Identifies orphaned accounts, mismatched
assignments, and provisioning failures.
"""
import argparse
import json
import os
import sys
from datetime import datetime, timezone
try:
import requests
except ImportError:
print("[!] 'requests' required: pip install requests", file=sys.stderr)
sys.exit(1)
def get_okta_config():
"""Return Okta org URL and API token."""
org_url = os.environ.get("OKTA_ORG_URL", "").rstrip("/")
api_token = os.environ.get("OKTA_API_TOKEN", "")
if not org_url or not api_token:
print("[!] Set OKTA_ORG_URL and OKTA_API_TOKEN env vars", file=sys.stderr)
sys.exit(1)
return org_url, api_token
def okta_api(org_url, token, endpoint, params=None):
"""Make authenticated Okta API call with pagination."""
url = f"{org_url}/api/v1{endpoint}"
headers = {"Authorization": f"SSWS {token}", "Accept": "application/json"}
all_results = []
while url:
resp = requests.get(url, headers=headers, params=params, timeout=30)
resp.raise_for_status()
all_results.extend(resp.json())
links = resp.links
url = links.get("next", {}).get("url")
params = None # Pagination URL includes params
return all_results
def list_provisioning_apps(org_url, token):
"""List applications with SCIM provisioning enabled."""
print("[*] Fetching provisioning-enabled applications...")
apps = okta_api(org_url, token, "/apps", params={"limit": 200})
scim_apps = []
for app in apps:
features = app.get("features", [])
if any(f in features for f in [
"PUSH_NEW_USERS", "PUSH_USER_DEACTIVATION",
"IMPORT_NEW_USERS", "PUSH_PROFILE_UPDATES"
]):
scim_apps.append({
"id": app.get("id"),
"name": app.get("name", ""),
"label": app.get("label", ""),
"status": app.get("status", ""),
"features": features,
"sign_on_mode": app.get("signOnMode", ""),
"created": app.get("created", ""),
})
print(f"[+] Found {len(scim_apps)} SCIM-enabled apps")
return scim_apps
def audit_app_assignments(org_url, token, app_id, app_label):
"""Audit user assignments for a provisioning app."""
findings = []
print(f"[*] Auditing assignments for: {app_label}")
users = okta_api(org_url, token, f"/apps/{app_id}/users", params={"limit": 200})
status_counts = {}
for user in users:
status = user.get("status", "unknown")
status_counts[status] = status_counts.get(status, 0) + 1
scope = user.get("scope", "")
sync_state = user.get("syncState", "")
if status == "PROVISIONED" and sync_state == "ERROR":
findings.append({
"app": app_label,
"user": user.get("credentials", {}).get("userName", "unknown"),
"check": "Provisioning sync error",
"severity": "HIGH",
"detail": f"User sync state: ERROR (status: {status})",
})
if status == "DEPROVISIONED":
findings.append({
"app": app_label,
"user": user.get("credentials", {}).get("userName", "unknown"),
"check": "Deprovisioned user still assigned",
"severity": "MEDIUM",
"detail": "User is deprovisioned but assignment exists",
})
findings.append({
"app": app_label,
"check": "Assignment summary",
"severity": "INFO",
"detail": f"Total: {len(users)}, Status: {json.dumps(status_counts)}",
})
return findings, users
def audit_group_assignments(org_url, token, app_id, app_label):
"""Audit group-based provisioning assignments."""
findings = []
groups = okta_api(org_url, token, f"/apps/{app_id}/groups", params={"limit": 200})
if not groups:
findings.append({
"app": app_label,
"check": "Group assignments",
"severity": "MEDIUM",
"detail": "No group assignments found (user-level only)",
})
else:
for group in groups:
group_id = group.get("id", "")
priority = group.get("priority", 0)
findings.append({
"app": app_label,
"check": f"Group assignment: {group_id}",
"severity": "INFO",
"detail": f"Priority: {priority}",
})
return findings
def check_deprovisioning(org_url, token):
"""Check for deactivated Okta users that still have active app assignments."""
findings = []
print("[*] Checking for orphaned provisioning assignments...")
deactivated = okta_api(org_url, token, "/users",
params={"filter": 'status eq "DEPROVISIONED"', "limit": 200})
for user in deactivated[:50]: # Check first 50
user_id = user.get("id")
login = user.get("profile", {}).get("login", "unknown")
try:
apps = okta_api(org_url, token, f"/users/{user_id}/appLinks")
if apps:
findings.append({
"check": "Orphaned app assignment",
"user": login,
"severity": "HIGH",
"detail": f"Deactivated user has {len(apps)} active app link(s)",
"apps": [a.get("appName", "") for a in apps[:5]],
})
except requests.RequestException:
pass
if not findings:
findings.append({
"check": "Deprovisioning audit",
"severity": "INFO",
"detail": f"No orphaned assignments found ({len(deactivated)} deactivated users checked)",
})
return findings
def format_summary(scim_apps, all_findings):
"""Print audit summary."""
print(f"\n{'='*60}")
print(f" Okta SCIM Provisioning Audit Report")
print(f"{'='*60}")
print(f" SCIM Apps : {len(scim_apps)}")
print(f" Findings : {len(all_findings)}")
severity_counts = {}
for f in all_findings:
sev = f.get("severity", "INFO")
severity_counts[sev] = severity_counts.get(sev, 0) + 1
print(f"\n By Severity:")
for sev in ["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"]:
count = severity_counts.get(sev, 0)
if count:
print(f" {sev:10s}: {count}")
if scim_apps:
print(f"\n Provisioning-Enabled Apps:")
for app in scim_apps:
print(f" {app['label']:30s} | {app['status']:10s} | "
f"Features: {', '.join(app['features'][:3])}")
issues = [f for f in all_findings if f["severity"] in ("CRITICAL", "HIGH")]
if issues:
print(f"\n Issues Requiring Attention:")
for f in issues[:15]:
print(f" [{f['severity']:8s}] {f['check']}: {f.get('detail', '')[:50]}")
return severity_counts
def main():
parser = argparse.ArgumentParser(description="Okta SCIM provisioning audit agent")
parser.add_argument("--org-url", help="Okta org URL (or OKTA_ORG_URL env)")
parser.add_argument("--token", help="API token (or OKTA_API_TOKEN env)")
parser.add_argument("--app-id", help="Audit a specific app ID")
parser.add_argument("--skip-deprovisioning", action="store_true")
parser.add_argument("--output", "-o", help="Output JSON report")
parser.add_argument("--verbose", "-v", action="store_true")
args = parser.parse_args()
if args.org_url:
os.environ["OKTA_ORG_URL"] = args.org_url
if args.token:
os.environ["OKTA_API_TOKEN"] = args.token
org_url, token = get_okta_config()
all_findings = []
scim_apps = list_provisioning_apps(org_url, token)
for app in scim_apps:
if args.app_id and app["id"] != args.app_id:
continue
findings, _ = audit_app_assignments(org_url, token, app["id"], app["label"])
all_findings.extend(findings)
group_findings = audit_group_assignments(org_url, token, app["id"], app["label"])
all_findings.extend(group_findings)
if not args.skip_deprovisioning:
all_findings.extend(check_deprovisioning(org_url, token))
severity_counts = format_summary(scim_apps, all_findings)
report = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"tool": "Okta SCIM Audit",
"scim_apps": scim_apps,
"findings": all_findings,
"severity_counts": severity_counts,
"risk_level": (
"CRITICAL" if severity_counts.get("CRITICAL", 0) > 0
else "HIGH" if severity_counts.get("HIGH", 0) > 0
else "MEDIUM" if severity_counts.get("MEDIUM", 0) > 0
else "LOW"
),
}
if args.output:
with open(args.output, "w") as f:
json.dump(report, f, indent=2)
print(f"\n[+] Report saved to {args.output}")
elif args.verbose:
print(json.dumps(report, indent=2))
if __name__ == "__main__":
main()