#!/usr/bin/env python3 """End-to-end driver for scripts/30-verify-offline.sh (python3 stdlib only). oauth-app create the Drone OAuth2 application in the test Gitea run log in to Drone through Gitea OAuth, create + activate two repos, push them, wait for the builds, save step logs, assert results """ import argparse import base64 import http.cookiejar import json import os import re import shutil import subprocess import sys import time import urllib.error import urllib.parse import urllib.request FINAL = {"success", "failure", "error", "killed", "skipped", "declined"} def log(msg): print(f"[{time.strftime('%H:%M:%SZ', time.gmtime())}] {msg}", flush=True) def api(method, url, body=None, headers=None, opener=None): data = json.dumps(body).encode() if body is not None else None req = urllib.request.Request(url, data=data, method=method, headers=dict(headers or {})) if data is not None: req.add_header("Content-Type", "application/json") op = opener or urllib.request.build_opener() try: with op.open(req, timeout=60) as r: raw = r.read() return json.loads(raw) if raw.strip() else None except urllib.error.HTTPError as e: sys.exit(f"{method} {url} -> HTTP {e.code}: {e.read()[:400]!r}") def basic(user, password): return {"Authorization": "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode()} def hidden_inputs(html, form_action): m = re.search(r']+action="[^"]*%s"[^>]*>(.*?)' % re.escape(form_action), html, re.S) if not m: return None fields = {} for tag in re.findall(r"]*>", m.group(1)): name = re.search(r'name="([^"]+)"', tag) value = re.search(r'value="([^"]*)"', tag) if name: fields[name.group(1)] = value.group(1) if value else "" return fields def cmd_oauth_app(a): app = api("POST", f"{a.gitea}/api/v1/user/applications/oauth2", {"name": "drone", "redirect_uris": [f"{a.drone}/login"], "confidential_client": True}, basic(a.user, a.password)) print(app["client_id"], app["client_secret"]) def oauth_login(a): """Drive the browser flow: Gitea login -> Drone /login -> grant -> Drone session.""" jar = http.cookiejar.CookieJar() op = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) page = op.open(f"{a.gitea}/user/login", timeout=30).read().decode() csrf = re.search(r'name="_csrf" value="([^"]+)"', page).group(1) form = urllib.parse.urlencode({"_csrf": csrf, "user_name": a.user, "password": a.password}).encode() r = op.open(f"{a.gitea}/user/login", data=form, timeout=30) if "/user/login" in r.geturl(): sys.exit("Gitea login failed") log("logged in to Gitea") r = op.open(f"{a.drone}/login", timeout=60) html = r.read().decode(errors="replace") if not r.geturl().startswith(a.drone): fields = hidden_inputs(html, "/login/oauth/grant") if fields is None: sys.exit(f"unexpected page at {r.geturl()}:\n{html[:600]}") fields["granted"] = "true" r = op.open(f"{a.gitea}/login/oauth/grant", data=urllib.parse.urlencode(fields).encode(), timeout=60) r.read() if not any(c.name == "_session_" for c in jar): sys.exit(f"Drone session cookie not set (ended at {r.geturl()})") log("OAuth grant accepted; Drone session established") def git(*args, cwd): subprocess.run(["git", *args], cwd=cwd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) def push_repo(a, name, src_dir, extra): work = os.path.join(a.workdir, name) shutil.rmtree(work, ignore_errors=True) shutil.copytree(src_dir, work) shutil.copy(a.pipeline, os.path.join(work, ".drone.yml")) for rel, content in extra.items(): path = os.path.join(work, rel) os.makedirs(os.path.dirname(path), exist_ok=True) if isinstance(content, bytes): open(path, "wb").write(content) elif content.startswith("@copy:"): shutil.copy(content[6:], path) else: open(path, "w").write(content) git("init", "-q", "-b", "main", cwd=work) git("-c", "user.name=e2e", "-c", "user.email=e2e@example.invalid", "add", "-A", cwd=work) git("-c", "user.name=e2e", "-c", "user.email=e2e@example.invalid", "commit", "-q", "-m", "e2e", cwd=work) host = urllib.parse.urlparse(a.gitea) remote = f"{host.scheme}://{a.user}:{urllib.parse.quote(a.password)}@{host.netloc}/{a.user}/{name}.git" git("push", "-q", remote, "main", cwd=work) def wait_build(a, name, auth, timeout=1800): base = f"{a.drone}/api/repos/{a.user}/{name}/builds" deadline = time.time() + timeout last = None while time.time() < deadline: builds = api("GET", base, headers=auth) or [] if builds: b = api("GET", f"{base}/{builds[0]['number']}", headers=auth) if b["status"] != last: log(f"{name}: build #{b['number']} {b['status']}") last = b["status"] if b["status"] in FINAL: return b time.sleep(5) sys.exit(f"{name}: build did not finish within {timeout}s") def save_logs(a, name, build, auth): out = os.path.join(a.evidence, name) os.makedirs(out, exist_ok=True) steps = {} for stage in build.get("stages", []): for step in stage.get("steps", []): lines = api("GET", f"{a.drone}/api/repos/{a.user}/{name}/builds/{build['number']}" f"/logs/{stage['number']}/{step['number']}", headers=auth) or [] text = "".join(l.get("out", "") for l in lines) fname = f"{step['number']:02d}-{step['name']}.log" open(os.path.join(out, fname), "w").write(text) steps[step["name"]] = {"status": step["status"], "exit_code": step.get("exit_code"), "log": text} json.dump(build, open(os.path.join(out, "build.json"), "w"), indent=2) return steps def cmd_run(a): auth = {"Authorization": f"Bearer {a.token}"} oauth_login(a) me = api("GET", f"{a.drone}/api/user", headers=auth) assert me["login"] == a.user and me["admin"], me log(f"Drone API user: {me['login']} (admin={me['admin']})") repos = { "demo-vulnerable": {"private": True, "src": os.path.join(a.demo, "vulnerable"), "extra": { # synthetic token assembled here, never stored in the bundle "config/settings.py": 'GITHUB_TOKEN = "ghp_' + "Qx7Kq2Zp" * 4 + 'Ab12"\n', "dist/image.tar": "@copy:" + a.image_archive}}, "demo-clean": {"private": False, "src": os.path.join(a.demo, "clean"), "extra": {}}, } for name, spec in repos.items(): api("POST", f"{a.gitea}/api/v1/user/repos", {"name": name, "private": spec["private"], "auto_init": False, "default_branch": "main"}, basic(a.user, a.password)) log("created Gitea repositories (demo-vulnerable is private: exercises netrc clone auth)") api("POST", f"{a.drone}/api/user/repos?async=false", headers=auth) for name in repos: r = api("POST", f"{a.drone}/api/repos/{a.user}/{name}", headers=auth) assert r["active"], r hooks = api("GET", f"{a.gitea}/api/v1/repos/{a.user}/demo-clean/hooks", headers=basic(a.user, a.password)) assert hooks and hooks[0]["config"]["url"].startswith(f"{a.drone}/hook"), hooks log(f"activated repos in Drone; Gitea webhook -> {hooks[0]['config']['url'].split('?')[0]}") for name, spec in repos.items(): push_repo(a, name, spec["src"], spec["extra"]) log(f"pushed {name}") results, failures, builds, steps = {}, [], {}, {} def check(cond, msg): if cond: log(f" ok {msg}") else: failures.append(msg) log(f" FAIL {msg}") for name in repos: builds[name] = wait_build(a, name, auth) steps[name] = save_logs(a, name, builds[name], auth) results[name] = {"status": builds[name]["status"], "steps": {k: {"status": s["status"], "exit_code": s["exit_code"]} for k, s in steps[name].items()}} v, vs = builds["demo-vulnerable"], steps["demo-vulnerable"] log("assertions: demo-vulnerable") check(v["status"] == "failure", "build status is failure (blocked by the gate)") for s in ("clone", "scanner-data", "sbom-syft", "grype", "trivy-fs", "trivy-image", "evidence"): check(vs.get(s, {}).get("status") == "success", f"step {s} succeeded") check(vs.get("security-gate", {}).get("status") == "failure", "step security-gate failed") gate = vs.get("security-gate", {}).get("log", "") blocking = gate.split("BLOCKING findings", 1)[1] if "BLOCKING findings" in gate else "" check("SECURITY GATE: FAIL" in gate, "gate printed SECURITY GATE: FAIL") check(re.search(r"^\s+vuln:grype\s", blocking, re.M) is not None, "gate blocks on Grype (SBOM) findings") check(re.search(r"^\s+vuln:trivy-fs\s", blocking, re.M) is not None, "gate blocks on Trivy filesystem findings") check("secret:trivy-fs" in blocking, "gate blocks on the synthetic secret") check("misconfig:trivy-fs" in blocking, "gate blocks on the Dockerfile misconfiguration") check(re.search(r"(?i)django", blocking) is not None, "Django vulnerabilities reported") check("packages catalogued" in vs.get("sbom-syft", {}).get("log", ""), "Syft produced an SBOM") # Drone echoes each command (with "+ ") before its output, so match output # lines only — the echoed script itself contains the "skipped" message text. img = vs.get("trivy-image", {}).get("log", "") check(re.search(r"^==== trivy image --input /drone/src/dist/image.tar", img, re.M) is not None and re.search(r"^==== syft /drone/src/dist/image.tar", img, re.M) is not None, "image archive scanned by Syft/Grype and Trivy (dist/image.tar)") check("ok grype-db" in vs.get("scanner-data", {}).get("log", ""), "DB age check passed") ev = vs.get("evidence", {}).get("log", "") check("security-reports-demo-vulnerable-1.tar.gz" in ev, "evidence archive created") c, cs = builds["demo-clean"], steps["demo-clean"] log("assertions: demo-clean") check(c["status"] == "success", "build status is success") check("SECURITY GATE: PASS" in cs.get("security-gate", {}).get("log", ""), "gate printed SECURITY GATE: PASS") check(re.search(r"^no dist/image.tar in this build - image scan skipped", cs.get("trivy-image", {}).get("log", ""), re.M) is not None, "image step skipped without dist/image.tar") all_logs = "".join(s["log"] for s in list(vs.values()) + list(cs.values())) check("127.0.0.1:9" not in all_logs and "proxyconnect" not in all_logs, "no step attempted Internet access (black-hole proxy never used)") summary = {"results": results, "failures": failures} json.dump(summary, open(os.path.join(a.evidence, "summary.json"), "w"), indent=2) if failures: sys.exit(f"{len(failures)} assertion(s) failed") log("ALL ASSERTIONS PASSED") def main(): p = argparse.ArgumentParser() sub = p.add_subparsers(dest="cmd", required=True) o = sub.add_parser("oauth-app") r = sub.add_parser("run") for sp in (o, r): sp.add_argument("--gitea", required=True) sp.add_argument("--drone", required=True) sp.add_argument("--user", required=True) sp.add_argument("--password", required=True) r.add_argument("--token", required=True) r.add_argument("--demo", required=True) r.add_argument("--pipeline", required=True) r.add_argument("--image-archive", required=True) r.add_argument("--workdir", required=True) r.add_argument("--evidence", required=True) a = p.parse_args() {"oauth-app": cmd_oauth_app, "run": cmd_run}[a.cmd](a) if __name__ == "__main__": main()