From 5859dcfaa15694904432caa8e632f2ed709745fd Mon Sep 17 00:00:00 2001 From: OM CHOKSI Date: Mon, 22 Jun 2026 14:22:39 +0530 Subject: [PATCH 1/2] feat: add SaaS website and GitHub OAuth foundation - Flask marketing website + dashboard (landing, login, dashboard, reviews, usage, contact, settings, error pages) - GitHub OAuth login flow with /auth/github/start, /auth/github/callback, /logout - MongoDB-backed user storage (users collection) with in-memory fallback - Resend welcome email on first sign-in (safe opt-in, never blocks login) - Usage display: 30 PR reviews/month with progress bar and remaining count - GitHub App install CTA button (configured via GITHUB_APP_SLUG env var) - API client: fetches review history from FastAPI backend - Render deployment: render.yaml + website.Dockerfile + start script - pyproject.toml: website extra (flask, requests, gunicorn) - docs/website.md: full setup, OAuth, deployment, limitations --- README.md | 20 +++++- deploy/render/website.Dockerfile | 17 +++++ deploy/render/website_start.sh | 5 ++ docs/website.md | 79 ++++++++++++++++++++++++ pyproject.toml | 7 ++- render.yaml | 35 +++++++++++ website/__init__.py | 0 website/api_client.py | 35 +++++++++++ website/app.py | 94 ++++++++++++++++++++++++++++ website/auth.py | 103 +++++++++++++++++++++++++++++++ website/config.py | 23 +++++++ website/db.py | 70 +++++++++++++++++++++ website/email_service.py | 56 +++++++++++++++++ website/static/css/styles.css | 100 ++++++++++++++++++++++++++++++ website/static/js/app.js | 6 ++ website/templates/base.html | 48 ++++++++++++++ website/templates/contact.html | 10 +++ website/templates/dashboard.html | 57 +++++++++++++++++ website/templates/error.html | 9 +++ website/templates/index.html | 41 ++++++++++++ website/templates/login.html | 13 ++++ website/templates/reviews.html | 30 +++++++++ website/templates/settings.html | 11 ++++ website/templates/usage.html | 14 +++++ website/usage.py | 22 +++++++ 25 files changed, 901 insertions(+), 4 deletions(-) create mode 100644 deploy/render/website.Dockerfile create mode 100644 deploy/render/website_start.sh create mode 100644 docs/website.md create mode 100644 render.yaml create mode 100644 website/__init__.py create mode 100644 website/api_client.py create mode 100644 website/app.py create mode 100644 website/auth.py create mode 100644 website/config.py create mode 100644 website/db.py create mode 100644 website/email_service.py create mode 100644 website/static/css/styles.css create mode 100644 website/static/js/app.js create mode 100644 website/templates/base.html create mode 100644 website/templates/contact.html create mode 100644 website/templates/dashboard.html create mode 100644 website/templates/error.html create mode 100644 website/templates/index.html create mode 100644 website/templates/login.html create mode 100644 website/templates/reviews.html create mode 100644 website/templates/settings.html create mode 100644 website/templates/usage.html create mode 100644 website/usage.py diff --git a/README.md b/README.md index 2f2afe9..8706016 100644 --- a/README.md +++ b/README.md @@ -17,12 +17,16 @@ CodeSecAudit AI acts like an AI senior engineer inside GitHub pull requests, det ## Live Links | Asset | Link | -|---|---| +|---|---|---| | Kaggle Notebook | https://www.kaggle.com/code/omchoksi04/codereview | | Hugging Face Dataset | https://huggingface.co/datasets/OMCHOKSI108/CodeSecAudit-RAG | | RAG Service (live) | https://OMCHOKSI108-codereview-agent.hf.space | +| API (live) | https://codesec-api.onrender.com | +| Dashboard (live) | https://codesec-dashboard.onrender.com | +| Review UI (live) | https://codesec-review-ui.onrender.com | | GitHub Repo | https://github.com/OMCHOKSI108/codesec-audit-dataset | | Deploy PR | https://github.com/OMCHOKSI108/codesec-audit-dataset/pull/1 | +| Website (coming soon) | https://codesec-website.onrender.com | --- @@ -156,6 +160,16 @@ docker compose up --build See [docs/docker.md](docs/docker.md) for RAG mode and build options. +### SaaS Website (Flask) + +```bash +pip install -e ".[website]" +flask --app website.app run --port 5000 +# Open http://localhost:5000 +``` + +See [docs/website.md](docs/website.md) for env vars and deployment. + --- ## GitHub Action Usage @@ -274,7 +288,7 @@ The user flow for the SaaS version: 6. Limit reached → Resend email with upgrade prompt 7. Contact owner for custom plans -Owner contact: **omchoksi108@gmail.com** +Owner contact: [omchoksi108@gmail.com](mailto:omchoksi108@gmail.com) Current status: - [x] Datasets + RAG corpus @@ -311,4 +325,4 @@ CodeSecAudit AI is a **defensive security tool**: For questions, custom plans, or limit increases: -**omchoksi108@gmail.com** +[omchoksi108@gmail.com](mailto:omchoksi108@gmail.com) diff --git a/deploy/render/website.Dockerfile b/deploy/render/website.Dockerfile new file mode 100644 index 0000000..7fdede4 --- /dev/null +++ b/deploy/render/website.Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY pyproject.toml README.md ./ +COPY review_engine/ review_engine/ +COPY website/ website/ +COPY config/ config/ + +RUN pip install --no-cache-dir -e ".[website]" + +COPY deploy/render/website_start.sh /website_start.sh +RUN chmod +x /website_start.sh + +EXPOSE 10000 + +CMD ["/website_start.sh"] diff --git a/deploy/render/website_start.sh b/deploy/render/website_start.sh new file mode 100644 index 0000000..2c43b2d --- /dev/null +++ b/deploy/render/website_start.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -e + +echo "Starting CodeSecAudit Website..." +exec gunicorn website.app:app --bind 0.0.0.0:${PORT:-10000} --workers 2 --timeout 60 --access-logfile - diff --git a/docs/website.md b/docs/website.md new file mode 100644 index 0000000..20ee654 --- /dev/null +++ b/docs/website.md @@ -0,0 +1,79 @@ +# CodeSecAudit AI — SaaS Website + +Flask-based marketing site + dashboard with GitHub OAuth login. + +--- + +## Local Run + +```bash +pip install -e ".[website]" +flask --app website.app run --port 5000 +# or +python -m website.app +``` + +Open http://localhost:5000 + +## Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `CODESEC_API_URL` | No | FastAPI backend URL (default: `http://localhost:8000`) | +| `PUBLIC_WEBSITE_URL` | No | Public website URL (default: `http://localhost:5000`) | +| `GITHUB_CLIENT_ID` | For OAuth | GitHub OAuth App client ID | +| `GITHUB_CLIENT_SECRET` | For OAuth | GitHub OAuth App client secret | +| `GITHUB_CALLBACK_URL` | No | OAuth callback (default: `/auth/github/callback`) | +| `SESSION_SECRET` | For production | Flask session secret key | +| `GITHUB_APP_SLUG` | No | GitHub App slug for install button | +| `MONGODB_URI` | No | MongoDB connection string | +| `MONGODB_DB_NAME` | No | MongoDB database name (default: `codesec_audit`) | +| `RESEND_API_KEY` | No | Resend API key for welcome emails | +| `EMAIL_FROM` | No | From address for emails | +| `OWNER_CONTACT_EMAIL` | No | Contact email (default: `omchoksi108@gmail.com`) | +| `FREE_PR_REVIEWS_PER_MONTH` | No | Free plan limit (default: `30`) | + +## GitHub OAuth Setup + +1. Go to **Settings → Developer settings → OAuth Apps → New OAuth App** +2. Fill: + - Application name: `CodeSecAudit AI (Dev)` + - Homepage URL: `http://localhost:5000` or your Render URL + - Authorization callback URL: `http://localhost:5000/auth/github/callback` or your Render URL +3. Copy `Client ID` and `Client Secret` +4. Set as `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` env vars + +## Render Deployment + +1. Create a new **Web Service** in Render dashboard +2. Connect your repo +3. Set: + - **Name:** `codesec-website` + - **Region:** same as API + - **Branch:** `main` + - **Runtime:** Docker + - **Dockerfile path:** `deploy/render/website.Dockerfile` + - **Health Check Path:** `/` +4. Add all env vars from the table above +5. Deploy + +Or use the `render.yaml` blueprint for automated setup. + +## MongoDB Behavior + +- If `MONGODB_URI` is set and reachable: user profiles are stored in MongoDB `users` collection +- If MongoDB is unavailable/unconfigured: in-memory dict fallback (lost on restart) +- Website never crashes due to DB issues + +## Resend Welcome Email + +- If `RESEND_API_KEY` is set: welcome email sent on first sign-in +- Email uses Resend HTTP API directly (no SDK needed) +- If email fails, login still succeeds (logged warning) + +## Limitations + +- Usage enforcement is **display-only**; actual limit checking is next step +- GitHub App install button appears if `GITHUB_APP_SLUG` is set; no webhook yet +- No account settings persistence (coming in next iteration) +- Review history depends on FastAPI backend availability diff --git a/pyproject.toml b/pyproject.toml index abbd7c3..99c5f42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,8 +32,13 @@ rag = [ "chromadb>=0.5.0", "sentence-transformers>=2.2.0", ] +website = [ + "flask>=3.0.0", + "requests>=2.31.0", + "gunicorn>=22.0.0", +] dev = [ - "codesec-audit-ai[api,ui,rag]", + "codesec-audit-ai[api,ui,rag,website]", ] [tool.hatch.build] diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..a6ccb3a --- /dev/null +++ b/render.yaml @@ -0,0 +1,35 @@ +services: + - type: web + name: codesec-website + runtime: docker + repo: https://github.com/OMCHOKSI108/codesec-audit-dataset + branch: main + dockerfilePath: ./deploy/render/website.Dockerfile + healthCheckPath: / + envVars: + - key: CODESEC_API_URL + value: https://codesec-api.onrender.com + - key: PUBLIC_WEBSITE_URL + value: https://codesec-website.onrender.com + - key: GITHUB_CLIENT_ID + sync: false + - key: GITHUB_CLIENT_SECRET + sync: false + - key: GITHUB_CALLBACK_URL + value: https://codesec-website.onrender.com/auth/github/callback + - key: SESSION_SECRET + generateValue: true + - key: GITHUB_APP_SLUG + sync: false + - key: MONGODB_URI + sync: false + - key: MONGODB_DB_NAME + value: codesec_audit + - key: RESEND_API_KEY + sync: false + - key: EMAIL_FROM + sync: false + - key: OWNER_CONTACT_EMAIL + value: omchoksi108@gmail.com + - key: FREE_PR_REVIEWS_PER_MONTH + value: "30" diff --git a/website/__init__.py b/website/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/website/api_client.py b/website/api_client.py new file mode 100644 index 0000000..2737bd7 --- /dev/null +++ b/website/api_client.py @@ -0,0 +1,35 @@ +import requests +from website.config import Config + + +def fetch_reviews(limit: int = 50, offset: int = 0) -> list[dict]: + try: + url = f"{Config.CODESEC_API_URL}/reviews" + resp = requests.get(url, params={"limit": limit, "offset": offset}, timeout=10) + if resp.status_code == 200: + return resp.json() + except requests.RequestException: + pass + return [] + + +def fetch_stats() -> dict: + try: + url = f"{Config.CODESEC_API_URL}/stats" + resp = requests.get(url, timeout=10) + if resp.status_code == 200: + return resp.json() + except requests.RequestException: + pass + return {} + + +def fetch_review(review_id: str) -> dict | None: + try: + url = f"{Config.CODESEC_API_URL}/reviews/{review_id}" + resp = requests.get(url, timeout=10) + if resp.status_code == 200: + return resp.json() + except requests.RequestException: + pass + return None diff --git a/website/app.py b/website/app.py new file mode 100644 index 0000000..33cef68 --- /dev/null +++ b/website/app.py @@ -0,0 +1,94 @@ +import os +import logging +from flask import Flask, render_template, session, redirect, url_for + +from website.config import Config +from website.auth import auth_bp +from website.api_client import fetch_reviews, fetch_stats +from website.usage import get_usage + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") +logger = logging.getLogger(__name__) + + +def create_app() -> Flask: + app = Flask(__name__) + app.config.from_object(Config) + app.config["SESSION_COOKIE_HTTPONLY"] = True + app.config["SESSION_COOKIE_SAMESITE"] = "Lax" + + app.register_blueprint(auth_bp) + + @app.context_processor + def inject_globals(): + slug = Config.GITHUB_APP_SLUG + return { + "github_app_url": f"https://github.com/apps/{slug}/installations/new" if slug else "", + "owner_email": Config.OWNER_CONTACT_EMAIL, + } + + @app.route("/") + def index(): + return render_template("index.html") + + @app.route("/login") + def login_page(): + if session.get("user"): + return redirect(url_for("dashboard")) + return render_template("login.html") + + @app.route("/dashboard") + def dashboard(): + user = session.get("user") + if not user: + return redirect(url_for("login_page")) + usage = get_usage(user) + reviews = [] + try: + reviews = fetch_reviews(limit=5) + except Exception: + pass + return render_template("dashboard.html", usage=usage, reviews=reviews) + + @app.route("/reviews") + def reviews_page(): + error = None + reviews = [] + try: + reviews = fetch_reviews(limit=100) + except Exception as e: + error = f"Could not load reviews from API: {e}" + return render_template("reviews.html", reviews=reviews, error=error) + + @app.route("/usage") + def usage_page(): + user = session.get("user") + usage = get_usage(user) + return render_template("usage.html", usage=usage) + + @app.route("/contact") + def contact_page(): + return render_template("contact.html") + + @app.route("/settings") + def settings_page(): + if not session.get("user"): + return redirect(url_for("login_page")) + return render_template("settings.html") + + @app.errorhandler(404) + def not_found(e): + return render_template("error.html", error="Page not found"), 404 + + @app.errorhandler(500) + def server_error(e): + return render_template("error.html", error="Internal server error"), 500 + + return app + + +app = create_app() + +if __name__ == "__main__": + port = int(os.environ.get("PORT", 5000)) + app.run(host="0.0.0.0", port=port, debug=True) diff --git a/website/auth.py b/website/auth.py new file mode 100644 index 0000000..620bd72 --- /dev/null +++ b/website/auth.py @@ -0,0 +1,103 @@ +import json +import logging +import urllib.parse +import requests +from flask import Blueprint, redirect, request, session, url_for +from website.config import Config +from website.db import upsert_user +from website.email_service import send_welcome_email + +logger = logging.getLogger(__name__) +auth_bp = Blueprint("auth", __name__) + + +@auth_bp.route("/auth/github/start") +def github_start(): + if not Config.GITHUB_CLIENT_ID: + return "GitHub OAuth not configured (GITHUB_CLIENT_ID missing)", 503 + params = { + "client_id": Config.GITHUB_CLIENT_ID, + "redirect_uri": Config.GITHUB_CALLBACK_URL, + "scope": "read:user user:email", + "state": session.get("_csrf_token", ""), + } + url = f"https://github.com/login/oauth/authorize?{urllib.parse.urlencode(params)}" + return redirect(url) + + +@auth_bp.route("/auth/github/callback") +def github_callback(): + code = request.args.get("code") + if not code: + return "Missing authorization code", 400 + + token_data = { + "client_id": Config.GITHUB_CLIENT_ID, + "client_secret": Config.GITHUB_CLIENT_SECRET, + "code": code, + } + try: + tok_resp = requests.post( + "https://github.com/login/oauth/access_token", + data=token_data, + headers={"Accept": "application/json"}, + timeout=15, + ) + if tok_resp.status_code != 200: + return f"Token exchange failed: {tok_resp.status_code}", 502 + access_token = tok_resp.json().get("access_token") + if not access_token: + return "No access_token in response", 502 + except requests.RequestException as e: + return f"Token exchange error: {e}", 502 + + headers = {"Authorization": f"Bearer {access_token}", "Accept": "application/vnd.github.v3+json"} + try: + user_resp = requests.get("https://api.github.com/user", headers=headers, timeout=15) + if user_resp.status_code != 200: + return f"User fetch failed: {user_resp.status_code}", 502 + profile = user_resp.json() + except requests.RequestException as e: + return f"User fetch error: {e}", 502 + + try: + email_resp = requests.get("https://api.github.com/user/emails", headers=headers, timeout=15) + emails = email_resp.json() if email_resp.status_code == 200 else [] + primary = next((e["email"] for e in emails if e.get("primary")), profile.get("email", "")) + except requests.RequestException: + primary = profile.get("email", "") + + user_data = { + "username": profile.get("login", ""), + "name": profile.get("name", "") or profile.get("login", ""), + "email": primary, + "avatar_url": profile.get("avatar_url", ""), + "plan": "free", + "reviews_limit": Config.FREE_PR_REVIEWS_PER_MONTH, + "extra_reviews": 0, + } + + user = upsert_user(str(profile["id"]), user_data) + was_new = user.get("created_at") == user.get("last_login_at") + session["user"] = { + "github_id": str(profile["id"]), + "username": user["username"], + "name": user.get("name", user["username"]), + "email": user.get("email", ""), + "avatar_url": user.get("avatar_url", ""), + "plan": user.get("plan", "free"), + "reviews_limit": user.get("reviews_limit", Config.FREE_PR_REVIEWS_PER_MONTH), + "reviews_used": user.get("reviews_used", 0), + "extra_reviews": user.get("extra_reviews", 0), + } + + if was_new: + send_welcome_email(user) + + return redirect(url_for("dashboard")) + + +@auth_bp.route("/logout") +def logout(): + session.clear() + return redirect(url_for("index")) diff --git a/website/config.py b/website/config.py new file mode 100644 index 0000000..27e1f9f --- /dev/null +++ b/website/config.py @@ -0,0 +1,23 @@ +import os + + +class Config: + SECRET_KEY = os.environ.get("SESSION_SECRET", os.urandom(32).hex()) + CODESEC_API_URL = os.environ.get("CODESEC_API_URL", "http://localhost:8000") + PUBLIC_WEBSITE_URL = os.environ.get("PUBLIC_WEBSITE_URL", "http://localhost:5000") + + GITHUB_CLIENT_ID = os.environ.get("GITHUB_CLIENT_ID", "") + GITHUB_CLIENT_SECRET = os.environ.get("GITHUB_CLIENT_SECRET", "") + GITHUB_CALLBACK_URL = os.environ.get( + "GITHUB_CALLBACK_URL", f"{PUBLIC_WEBSITE_URL}/auth/github/callback" + ) + GITHUB_APP_SLUG = os.environ.get("GITHUB_APP_SLUG", "") + + MONGODB_URI = os.environ.get("MONGODB_URI", "") + MONGODB_DB_NAME = os.environ.get("MONGODB_DB_NAME", "codesec_audit") + + RESEND_API_KEY = os.environ.get("RESEND_API_KEY", "") + EMAIL_FROM = os.environ.get("EMAIL_FROM", "") + OWNER_CONTACT_EMAIL = os.environ.get("OWNER_CONTACT_EMAIL", "omchoksi108@gmail.com") + + FREE_PR_REVIEWS_PER_MONTH = int(os.environ.get("FREE_PR_REVIEWS_PER_MONTH", "30")) diff --git a/website/db.py b/website/db.py new file mode 100644 index 0000000..b9c5a0e --- /dev/null +++ b/website/db.py @@ -0,0 +1,70 @@ +import os +from datetime import datetime, timezone +from website.config import Config + +_users: dict[str, dict] = {} +_mongo = None +_mongo_db = None + + +def _get_mongo(): + global _mongo, _mongo_db + if _mongo is None and Config.MONGODB_URI: + try: + from pymongo import MongoClient + _mongo = MongoClient(Config.MONGODB_URI, serverSelectionTimeoutMS=3000) + _mongo.admin.command("ping") + _mongo_db = _mongo[Config.MONGODB_DB_NAME] + except Exception: + _mongo = False + return _mongo_db if _mongo and _mongo is not False else None + + +def is_mongo_connected() -> bool: + return _get_mongo() is not None + + +def upsert_user(github_id: str, data: dict) -> dict: + now = datetime.now(timezone.utc).isoformat() + data["github_id"] = github_id + data["last_login_at"] = now + + db = _get_mongo() + if db: + existing = db.users.find_one({"github_id": github_id}) + if existing: + db.users.update_one({"github_id": github_id}, {"$set": data, "$setOnInsert": {"created_at": now}}) + merged = {**existing, **data} + else: + data["created_at"] = now + db.users.insert_one(data) + merged = data + return merged + + existing = _users.get(github_id) + if existing: + existing.update(data) + return existing + data["created_at"] = now + _users[github_id] = data + return data + + +def get_user(github_id: str) -> dict | None: + db = _get_mongo() + if db: + return db.users.find_one({"github_id": github_id}, {"_id": 0}) + return _users.get(github_id) + + +def update_usage(github_id: str, reviews_used: int) -> None: + db = _get_mongo() + if db: + db.users.update_one( + {"github_id": github_id}, + {"$set": {"reviews_used": reviews_used}} + ) + return + user = _users.get(github_id) + if user: + user["reviews_used"] = reviews_used diff --git a/website/email_service.py b/website/email_service.py new file mode 100644 index 0000000..4a34b11 --- /dev/null +++ b/website/email_service.py @@ -0,0 +1,56 @@ +import logging +import requests +from website.config import Config + +logger = logging.getLogger(__name__) + + +def send_welcome_email(user: dict) -> bool: + if not Config.RESEND_API_KEY: + logger.info("RESEND_API_KEY not set, skipping welcome email") + return False + email = user.get("email", "") + username = user.get("username", "there") + if not email: + logger.info("No email available, skipping welcome email") + return False + to_name = user.get("name", "") or username + try: + resp = requests.post( + "https://api.resend.com/emails", + headers={ + "Authorization": f"Bearer {Config.RESEND_API_KEY}", + "Content-Type": "application/json", + }, + json={ + "from": Config.EMAIL_FROM or "CodeSecAudit ", + "to": [email], + "subject": "Welcome to CodeSecAudit AI — your PRs are now protected", + "html": _build_welcome_html(to_name), + }, + timeout=15, + ) + if resp.status_code in (200, 201): + logger.info("Welcome email sent to %s", email) + return True + logger.warning("Welcome email failed: %s %s", resp.status_code, resp.text[:200]) + except requests.RequestException as e: + logger.warning("Welcome email request failed: %s", e) + return False + + +def _build_welcome_html(name: str) -> str: + dashboard_url = f"{Config.PUBLIC_WEBSITE_URL}/dashboard" + return f""" + +

Welcome to CodeSecAudit AI, {name}!

+

Your pull requests are now protected by OWASP-based security review.

+
    +
  • 30 free PR reviews per month
  • +
  • Automated CWE detection
  • +
  • Inline comments and risk scoring
  • +
  • RAG-powered fix suggestions
  • +
+

Go to Dashboard

+

Contact {Config.OWNER_CONTACT_EMAIL} for questions.

+""" diff --git a/website/static/css/styles.css b/website/static/css/styles.css new file mode 100644 index 0000000..7a03ba8 --- /dev/null +++ b/website/static/css/styles.css @@ -0,0 +1,100 @@ +*,*::before,*::after{box-sizing:border-box;margin:0;padding:0} +:root{--bg:#0f172a;--bg2:#1e293b;--bg3:#334155;--fg:#e2e8f0;--fg2:#94a3b8;--accent:#3b82f6;--accent2:#2563eb;--danger:#ef4444;--warn:#f59e0b;--good:#22c55e;--border:#334155;--radius:8px;--max-w:1200px} +body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--fg);line-height:1.6;min-height:100vh} +.container{max-width:var(--max-w);margin:0 auto;padding:0 20px} + +/* Nav */ +.navbar{background:var(--bg2);border-bottom:1px solid var(--border);padding:12px 0;position:sticky;top:0;z-index:50} +.nav-inner{display:flex;align-items:center;justify-content:space-between} +.nav-brand{font-size:1.2rem;font-weight:700;color:var(--accent);text-decoration:none} +.nav-links{display:flex;align-items:center;gap:16px} +.nav-links a{color:var(--fg2);text-decoration:none;font-size:0.9rem;transition:color .15s} +.nav-links a:hover{color:var(--fg)} +.nav-user{display:flex;align-items:center;gap:8px;font-size:0.9rem} +.nav-avatar{border-radius:50%} + +/* Hero */ +.hero{text-align:center;padding:80px 0 40px} +.hero h1{font-size:2.5rem;max-width:800px;margin:0 auto 16px;line-height:1.2} +.hero-sub{color:var(--fg2);font-size:1.1rem;max-width:600px;margin:0 auto 32px} +.hero-cta{display:flex;gap:12px;justify-content:center;flex-wrap:wrap} + +/* Buttons */ +.btn-primary,.btn-secondary,.btn-small,.btn-disabled{display:inline-flex;align-items:center;gap:8px;padding:10px 24px;border-radius:var(--radius);font-size:0.95rem;font-weight:600;text-decoration:none;cursor:pointer;border:none;transition:all .15s} +.btn-primary{background:var(--accent);color:#fff} +.btn-primary:hover{background:var(--accent2)} +.btn-secondary{background:var(--bg3);color:var(--fg)} +.btn-secondary:hover{background:var(--border)} +.btn-small{padding:6px 14px;font-size:0.85rem;background:var(--bg3);color:var(--fg)} +.btn-disabled{padding:10px 24px;background:var(--bg3);color:var(--fg2);cursor:not-allowed;opacity:0.6;border-radius:var(--radius)} +.btn-github{background:#24292e;color:#fff} +.btn-github:hover{background:#1b1f23} + +/* Features */ +.features{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px;padding:40px 0} +.feature-card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:24px;transition:border-color .15s} +.feature-card:hover{border-color:var(--accent)} +.feature-card h3{margin-bottom:8px;font-size:1.1rem} +.feature-card p{color:var(--fg2);font-size:0.9rem} + +.plan-badge{text-align:center;padding:12px;margin:0 0 40px;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);color:var(--fg2)} +.links-section h2{text-align:center;margin-bottom:16px} +.link-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;padding-bottom:40px} +.link-card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:16px;text-align:center;color:var(--fg);text-decoration:none;transition:border-color .15s} +.link-card:hover{border-color:var(--accent)} + +/* Form card */ +.form-card{max-width:480px;margin:60px auto;text-align:center;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:40px} +.form-card h1{margin-bottom:12px} +.form-card p{color:var(--fg2);margin-bottom:12px} +.plan-note{margin-bottom:24px;padding:8px;background:var(--bg3);border-radius:var(--radius)} + +/* Dashboard */ +.dashboard-header{display:flex;gap:24px;flex-wrap:wrap;padding:24px 0} +.user-info{display:flex;align-items:center;gap:16px;flex:1} +.user-avatar{border-radius:50%} +.plan-label{color:var(--fg2);margin-top:4px} +.usage-card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:20px;min-width:220px} +.usage-card h3{margin-bottom:8px} +.cta-section{display:flex;gap:12px;flex-wrap:wrap;padding:16px 0} +.section{padding:16px 0} +.section h3{margin-bottom:12px} +.muted{color:var(--fg2)} + +/* Table */ +.table{width:100%;border-collapse:collapse;margin:12px 0} +.table th,.table td{text-align:left;padding:10px 12px;border-bottom:1px solid var(--border);font-size:0.9rem} +.table th{color:var(--fg2);font-weight:600} +.table code{background:var(--bg3);padding:2px 6px;border-radius:4px;font-size:0.85rem} + +/* Badge */ +.badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.8rem;font-weight:600} +.badge-approve{background:rgba(34,197,94,0.15);color:var(--good)} +.badge-warning{background:rgba(245,158,11,0.15);color:var(--warn)} +.badge-request_changes{background:rgba(239,68,68,0.15);color:var(--danger)} + +/* Progress */ +.progress-bar{width:100%;height:8px;background:var(--bg3);border-radius:4px;overflow:hidden;margin:8px 0} +.progress-fill{height:100%;background:var(--accent);border-radius:4px;transition:width .3s} +.progress-bar.lg{height:16px} + +/* Usage detail */ +.usage-detail{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;padding:20px 0} +.usage-stat{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:16px} +.stat-label{display:block;font-size:0.85rem;color:var(--fg2);margin-bottom:4px} +.stat-value{font-size:1.5rem;font-weight:700} + +/* Flash */ +.flash{padding:12px 16px;border-radius:var(--radius);margin:12px 0;font-size:0.9rem} +.flash-error{background:rgba(239,68,68,0.15);color:var(--danger);border:1px solid rgba(239,68,68,0.3)} +.flash-success{background:rgba(34,197,94,0.15);color:var(--good);border:1px solid rgba(34,197,94,0.3)} + +/* Footer */ +.footer{border-top:1px solid var(--border);padding:20px 0;margin-top:60px;color:var(--fg2);font-size:0.85rem} +.footer-inner{display:flex;justify-content:space-between;align-items:center} +.footer a{color:var(--fg2);text-decoration:none} +.footer a:hover{color:var(--fg)} + +.settings-card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:24px;max-width:480px} +.settings-card h3{margin-bottom:12px} +.settings-card p{margin-bottom:8px;color:var(--fg2)} diff --git a/website/static/js/app.js b/website/static/js/app.js new file mode 100644 index 0000000..d0932d9 --- /dev/null +++ b/website/static/js/app.js @@ -0,0 +1,6 @@ +document.addEventListener("DOMContentLoaded", function () { + // Flash message auto-dismiss + document.querySelectorAll(".flash").forEach(function (el) { + setTimeout(function () { el.style.opacity = "0"; setTimeout(function () { el.remove(); }, 300); }, 4000); + }); +}); diff --git a/website/templates/base.html b/website/templates/base.html new file mode 100644 index 0000000..e26a0c7 --- /dev/null +++ b/website/templates/base.html @@ -0,0 +1,48 @@ + + + + + + {% block title %}CodeSecAudit AI{% endblock %} + + + + + +
+ {% for cat, msg in get_flashed_messages(with_categories=true) %} +
{{ msg }}
+ {% endfor %} + {% block content %}{% endblock %} +
+ + + + + + diff --git a/website/templates/contact.html b/website/templates/contact.html new file mode 100644 index 0000000..5709e7e --- /dev/null +++ b/website/templates/contact.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} +{% block title %}Contact — CodeSecAudit AI{% endblock %} +{% block content %} +
+

Contact

+

Need more PR reviews, have questions, or want to upgrade?

+

Email the owner:

+

{{ owner_email }}

+
+{% endblock %} diff --git a/website/templates/dashboard.html b/website/templates/dashboard.html new file mode 100644 index 0000000..82bb3cc --- /dev/null +++ b/website/templates/dashboard.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% block title %}Dashboard — CodeSecAudit AI{% endblock %} +{% block content %} +
+ +
+

Usage

+

{{ usage.used }} / {{ usage.limit }} reviews used

+

{{ usage.remaining }} remaining

+
+ Details +
+
+ +
+ {% if github_app_url %} + Install GitHub App + {% else %} + GitHub App install coming soon + {% endif %} + Contact Owner for More Usage +
+ +
+

Installed Repositories

+

GitHub App install tracking coming soon.

+
+ +
+

Recent Reviews

+ {% if reviews %} + + + + {% for r in reviews[:5] %} + + + + + + + + {% endfor %} + +
FileIssuesRiskVerdictDate
{{ r.file_path or '—' }}{{ r.issues|length if r.issues else 0 }}{{ r.risk_score }}/100{{ r.verdict }}{{ r.created_at[:10] if r.created_at else '—' }}
+ View All + {% else %} +

No reviews yet. Install the GitHub App and open a PR.

+ {% endif %} +
+{% endblock %} diff --git a/website/templates/error.html b/website/templates/error.html new file mode 100644 index 0000000..9bc9a75 --- /dev/null +++ b/website/templates/error.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} +{% block title %}Error — CodeSecAudit AI{% endblock %} +{% block content %} +
+

Something went wrong

+

{{ error|default('An unexpected error occurred.') }}

+ Go Home +
+{% endblock %} diff --git a/website/templates/index.html b/website/templates/index.html new file mode 100644 index 0000000..c4c641a --- /dev/null +++ b/website/templates/index.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} +{% block title %}CodeSecAudit AI — OWASP-Powered Security Review{% endblock %} +{% block content %} +
+

Automated Security Review for Your Pull Requests

+

Detect OWASP Top 10 vulnerabilities before they reach production. Powered by RAG-augmented detection with inline fix suggestions.

+
+ {% if session.get('user') %} + Go to Dashboard + {% else %} + Sign in with GitHub + {% endif %} + {% if github_app_url %} + Install GitHub App + {% else %} + GitHub App install coming soon + {% endif %} +
+
+ +
+

PR Summary

Every PR gets a clear security summary with severity breakdown.

+

Inline Comments

Issues are posted as review comments directly on the affected lines.

+

Risk Score

Each review computes a 0–100 risk score based on CWE severity and count.

+

CWE Detection

7 rule-based detectors covering Code Injection, SQLi, Command Injection, weak hashes, hardcoded secrets, path traversal, and SSRF.

+

RAG Guidance

Contextual fix suggestions retrieved from OWASP cheat sheets via ChromaDB.

+

Dashboard Analytics

Track review history, usage, and severity trends over time.

+
+ +
Free plan: 30 PR reviews per month. No credit card required.
+ + +{% endblock %} diff --git a/website/templates/login.html b/website/templates/login.html new file mode 100644 index 0000000..d79fdb4 --- /dev/null +++ b/website/templates/login.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} +{% block title %}Sign In — CodeSecAudit AI{% endblock %} +{% block content %} +
+

Sign In

+

Sign in with GitHub to start reviewing your pull requests.

+

Free plan: 30 PR reviews per month.

+ + + Sign in with GitHub + +
+{% endblock %} diff --git a/website/templates/reviews.html b/website/templates/reviews.html new file mode 100644 index 0000000..fbb85a7 --- /dev/null +++ b/website/templates/reviews.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% block title %}Reviews — CodeSecAudit AI{% endblock %} +{% block content %} +

Review History

+{% if error %} +
{{ error }}
+{% endif %} +{% if reviews %} + + + + + + {% for r in reviews %} + + + + + + + + + + {% endfor %} + +
IDSourceFileIssuesRiskVerdictDate
{{ r.id[:12] }}…{{ r.source }}{{ r.file_path or '—' }}{{ r.issues|length if r.issues else 0 }}{{ r.risk_score }}/100{{ r.verdict }}{{ r.created_at[:10] if r.created_at else '—' }}
+{% else %} +

No reviews found.

+{% endif %} +{% endblock %} diff --git a/website/templates/settings.html b/website/templates/settings.html new file mode 100644 index 0000000..bd04876 --- /dev/null +++ b/website/templates/settings.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block title %}Settings — CodeSecAudit AI{% endblock %} +{% block content %} +

Settings

+
+

Account

+

Username: {{ session.user.username }}

+

Email: {{ session.user.email or 'Not provided' }}

+

Plan: Free

+
+{% endblock %} diff --git a/website/templates/usage.html b/website/templates/usage.html new file mode 100644 index 0000000..9eeb45d --- /dev/null +++ b/website/templates/usage.html @@ -0,0 +1,14 @@ +{% extends "base.html" %} +{% block title %}Usage — CodeSecAudit AI{% endblock %} +{% block content %} +

Usage

+
+
PlanFree
+
Monthly Limit{{ usage.limit }} PR reviews
+
Used{{ usage.used }}
+
Remaining{{ usage.remaining }}
+
+
+

Resets at the start of each month.

+Need more? Contact the owner +{% endblock %} diff --git a/website/usage.py b/website/usage.py new file mode 100644 index 0000000..e4ba070 --- /dev/null +++ b/website/usage.py @@ -0,0 +1,22 @@ +from website.config import Config + + +def get_usage(user: dict | None) -> dict: + limit = Config.FREE_PR_REVIEWS_PER_MONTH + used = (user or {}).get("reviews_used", 0) + remaining = max(0, limit - used) + percent = min(100, round((used / limit) * 100)) if limit > 0 else 0 + return { + "limit": limit, + "used": used, + "remaining": remaining, + "percent": percent, + } + + +def remaining_reviews(user: dict | None) -> int: + return get_usage(user)["remaining"] + + +def usage_percent(user: dict | None) -> int: + return get_usage(user)["percent"] From 8cdc2ee44802f4b78baec8b2f555f76c7863a4c6 Mon Sep 17 00:00:00 2001 From: OM CHOKSI Date: Mon, 22 Jun 2026 14:34:23 +0530 Subject: [PATCH 2/2] feat: add GitHub App manifest CLI setup - create_github_app_manifest.py: generates manifest JSON + prints registration URL (supports --org for org-level apps, --callback-url, --webhook-url) - complete_github_app_manifest.py: exchanges manifest code for credentials via POST /app-manifests/{code}/conversions, saves to secrets/ - apply_github_app_env.py: safe .env updater (dry-run by default, --write to apply) - verify_github_app_env.py: checks 8 env vars are set without printing values - docs/github_app_setup.md: full setup guide with permissions, events, security - .gitignore: added secrets/, *.pem, github_app_credentials.local.json - README.md: added GitHub App Setup section with quick commands --- .gitignore | 3 + README.md | 11 ++ docs/github_app_setup.md | 146 ++++++++++++++++++++++++ scripts/apply_github_app_env.py | 108 ++++++++++++++++++ scripts/complete_github_app_manifest.py | 115 +++++++++++++++++++ scripts/create_github_app_manifest.py | 129 +++++++++++++++++++++ scripts/verify_github_app_env.py | 46 ++++++++ 7 files changed, 558 insertions(+) create mode 100644 docs/github_app_setup.md create mode 100644 scripts/apply_github_app_env.py create mode 100644 scripts/complete_github_app_manifest.py create mode 100644 scripts/create_github_app_manifest.py create mode 100644 scripts/verify_github_app_env.py diff --git a/.gitignore b/.gitignore index 2667813..5725b6f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ .env .env.* kaggle.json +secrets/ +*.pem +github_app_credentials.local.json # release temp files release/ diff --git a/README.md b/README.md index 8706016..1afea4e 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,17 @@ flask --app website.app run --port 5000 See [docs/website.md](docs/website.md) for env vars and deployment. +### GitHub App Setup + +```bash +python scripts/create_github_app_manifest.py +# Open the URL → create app → run: +python scripts/complete_github_app_manifest.py --code YOUR_CODE +python scripts/apply_github_app_env.py --write +``` + +See [docs/github_app_setup.md](docs/github_app_setup.md) for full instructions. + --- ## GitHub Action Usage diff --git a/docs/github_app_setup.md b/docs/github_app_setup.md new file mode 100644 index 0000000..d13841a --- /dev/null +++ b/docs/github_app_setup.md @@ -0,0 +1,146 @@ +# GitHub App Setup + +CodeSecAudit AI uses the **GitHub App Manifest flow** to create its GitHub App without manually filling every field in the GitHub UI. + +--- + +## Prerequisites + +- Python 3.10+ +- `requests` library (`pip install requests`) +- A GitHub account + +--- + +## Quick Setup + +### 1. Generate the manifest + +```bash +python scripts/create_github_app_manifest.py +``` + +This prints a URL. Open it in your browser. + +> For an organization, use: +> ```bash +> python scripts/create_github_app_manifest.py --org YOUR_ORG_NAME +> ``` + +Required callback/webhook URLs can be passed as args: + +```bash +python scripts/create_github_app_manifest.py \ + --callback-url https://your-website.onrender.com/auth/github/callback \ + --webhook-url https://your-api.onrender.com/webhook/github +``` + +Or set env vars: + +```bash +export GITHUB_CALLBACK_URL=https://your-website.onrender.com/auth/github/callback +export GITHUB_WEBHOOK_URL=https://your-api.onrender.com/webhook/github +``` + +### 2. Create the app in GitHub + +1. Open the printed URL in your browser. +2. Review the pre-filled fields. +3. Click **"Create GitHub App"**. +4. GitHub redirects to the `redirect_url` with a `?code=...` parameter. +5. Copy the `code` value from the URL. + +### 3. Exchange the code for credentials + +```bash +python scripts/complete_github_app_manifest.py --code YOUR_TEMP_CODE +``` + +This: + +- Calls `POST /app-manifests/{code}/conversions` +- Saves credentials to `secrets/github_app_credentials.local.json` +- Prints the App ID, slug, and Client ID +- Shows safe `.env` template + +### 4. Apply credentials to `.env` + +Dry-run first: + +```bash +python scripts/apply_github_app_env.py +``` + +To write: + +```bash +python scripts/apply_github_app_env.py --write +``` + +This sets: + +```text +GITHUB_APP_ID +GITHUB_APP_SLUG +GITHUB_CLIENT_ID +GITHUB_CLIENT_SECRET +GITHUB_WEBHOOK_SECRET +GITHUB_PRIVATE_KEY_BASE64 +``` + +### 5. Verify + +```bash +python scripts/verify_github_app_env.py +``` + +--- + +## Permissions + +| Permission | Level | Why | +|------------|-------|-----| +| **Contents** | Read | Read PR file contents for review | +| **Pull requests** | Read & write | Post summary comments and inline review comments | +| **Issues** | Read & write | Post review results as issue comments | +| **Checks** | Read & write | Create check runs for status visibility | +| **Metadata** | Read | Access repo metadata for PR context | + +## Events + +| Event | Why | +|-------|-----| +| **Pull request** | Trigger review on `opened` and `synchronize` | +| **Installation** | Track which repos the app is installed on | +| **Installation repositories** | Track repo additions/removals | + +## Security + +- **Never commit** the private key (`.pem` file) +- **Never commit** `.env` +- **Never commit** `secrets/github_app_credentials.local.json` +- Keep the webhook secret private +- Store secrets in Render environment variables (not in the repo) + +## Render Deployment + +Set these env vars on your Render services: + +| Variable | Service | Description | +|----------|---------|-------------| +| `GITHUB_APP_ID` | API, Website | GitHub App numeric ID | +| `GITHUB_APP_SLUG` | Website | App slug for install button URL | +| `GITHUB_CLIENT_ID` | Website | GitHub OAuth Client ID | +| `GITHUB_CLIENT_SECRET` | Website | GitHub OAuth Client Secret | +| `GITHUB_WEBHOOK_SECRET` | API | Webhook secret for payload verification | +| `GITHUB_PRIVATE_KEY_BASE64` | API | Base64-encoded App private key | +| `GITHUB_CALLBACK_URL` | Website | Full callback URL (`https://website/auth/github/callback`) | +| `GITHUB_WEBHOOK_URL` | API | Full webhook URL (`https://api/webhook/github`) | + +--- + +## Next Steps + +- GitHub App webhook review processing is the next backend phase +- The manifest scripts handle **registration only** +- Install the app on a repo after creation to enable webhook delivery diff --git a/scripts/apply_github_app_env.py b/scripts/apply_github_app_env.py new file mode 100644 index 0000000..55ef7d6 --- /dev/null +++ b/scripts/apply_github_app_env.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Apply saved GitHub App credentials to .env (safe dry-run by default). + +Usage: + python scripts/apply_github_app_env.py # dry-run + python scripts/apply_github_app_env.py --write # actually write + python scripts/apply_github_app_env.py --help +""" +import argparse +import json +import os +import sys +from pathlib import Path + +SECRETS_FILE = Path("secrets/github_app_credentials.local.json") +ENV_FILE = Path(".env") + +# Map credential keys to .env variable names +KEY_MAP = { + "app_id": "GITHUB_APP_ID", + "slug": "GITHUB_APP_SLUG", + "client_id": "GITHUB_CLIENT_ID", + "client_secret": "GITHUB_CLIENT_SECRET", + "webhook_secret": "GITHUB_WEBHOOK_SECRET", + "pem_base64": "GITHUB_PRIVATE_KEY_BASE64", +} + + +def load_credentials(path: Path) -> dict: + if not path.exists(): + print(f"Credentials not found: {path}", file=sys.stderr) + print("Run: python scripts/complete_github_app_manifest.py --code YOUR_CODE", file=sys.stderr) + sys.exit(1) + with open(path) as f: + return json.load(f) + + +def read_env(path: Path) -> dict: + env = {} + if not path.exists(): + return env + with open(path) as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, val = line.partition("=") + env[key.strip()] = val.strip() + return env + + +def write_env(path: Path, env: dict): + with open(path, "w") as f: + for key, val in env.items(): + f.write(f"{key}={val}\n") + + +def main(): + parser = argparse.ArgumentParser( + description="Apply GitHub App credentials to .env" + ) + parser.add_argument("--write", action="store_true", help="Actually write to .env") + args = parser.parse_args() + + creds = load_credentials(SECRETS_FILE) + current = read_env(ENV_FILE) + + updates = {} + for cred_key, env_key in KEY_MAP.items(): + val = creds.get(cred_key, "") + if val: + updates[env_key] = val + + print("=" * 72) + print(" Apply GitHub App Credentials to .env") + print("=" * 72) + print() + if args.write: + print(" Mode: WRITE") + else: + print(" Mode: DRY-RUN (pass --write to apply)") + print() + + changed = 0 + for env_key, val in updates.items(): + old = current.get(env_key, "") + status = "update" if old else " new" + if old: + changed += 1 + print(f" [{status}] {env_key}") + if not updates: + print(" No credentials to apply.") + + print() + if args.write: + current.update(updates) + write_env(ENV_FILE, current) + print(f" Written {len(updates)} keys to {ENV_FILE}") + else: + if changed: + print(f" {changed} key(s) would be updated.") + print(" Run with --write to apply.") + print() + print("=" * 72) + + +if __name__ == "__main__": + main() diff --git a/scripts/complete_github_app_manifest.py b/scripts/complete_github_app_manifest.py new file mode 100644 index 0000000..b671806 --- /dev/null +++ b/scripts/complete_github_app_manifest.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Exchange a GitHub App manifest code for credentials. + +Usage: + python scripts/complete_github_app_manifest.py --code TEMP_CODE + python scripts/complete_github_app_manifest.py --help +""" +import argparse +import base64 +import json +import os +import sys +from pathlib import Path + +try: + import requests +except ImportError: + print("Install requests: pip install requests", file=sys.stderr) + sys.exit(1) + + +GITHUB_API = "https://api.github.com" +SECRETS_DIR = Path("secrets") +CREDENTIALS_FILE = SECRETS_DIR / "github_app_credentials.local.json" + + +def exchange_code(code: str) -> dict: + url = f"{GITHUB_API}/app-manifests/{code}/conversions" + resp = requests.post( + url, + headers={ + "Accept": "application/vnd.github.v3+json", + "User-Agent": "codesec-audit-ai", + }, + timeout=30, + ) + if resp.status_code != 201: + print(f"Error: GitHub API returned {resp.status_code}", file=sys.stderr) + print(resp.text[:500], file=sys.stderr) + sys.exit(1) + return resp.json() + + +def build_output(creds: dict) -> dict: + pem = creds.get("pem", "") + pem_b64 = base64.b64encode(pem.encode()).decode() if pem else "" + return { + "app_id": str(creds.get("id", "")), + "slug": creds.get("slug", ""), + "client_id": creds.get("client_id", ""), + "client_secret": creds.get("client_secret", ""), + "webhook_secret": creds.get("webhook_secret", ""), + "pem": pem, + "pem_base64": pem_b64, + } + + +def save_credentials(data: dict) -> str: + SECRETS_DIR.mkdir(parents=True, exist_ok=True) + with open(CREDENTIALS_FILE, "w") as f: + json.dump(data, f, indent=2) + return str(CREDENTIALS_FILE.resolve()) + + +def print_safe_summary(data: dict): + print("=" * 72) + print(" GitHub App Registered Successfully") + print("=" * 72) + print() + print(f" App ID: {data['app_id']}") + print(f" Slug: {data['slug']}") + print(f" Client ID: {data['client_id']}") + print() + print(" Credentials saved to: secrets/github_app_credentials.local.json") + print() + print(" Set these environment variables (values hidden):") + print() + print(f" GITHUB_APP_ID={data['app_id']}") + print(f" GITHUB_APP_SLUG={data['slug']}") + print(f" GITHUB_CLIENT_ID={data['client_id']}") + print(f" GITHUB_CLIENT_SECRET=") + print(f" GITHUB_WEBHOOK_SECRET=") + print(f" GITHUB_PRIVATE_KEY_BASE64=") + print() + print(" To apply to .env:") + print(" python scripts/apply_github_app_env.py") + print(" python scripts/apply_github_app_env.py --write (to actually write)") + print() + print(" To apply to Render:") + print(" Use the Render dashboard or CLI to set these env vars:") + print(" - GITHUB_APP_ID") + print(" - GITHUB_APP_SLUG") + print(" - GITHUB_CLIENT_ID") + print(" - GITHUB_CLIENT_SECRET") + print(" - GITHUB_WEBHOOK_SECRET") + print(" - GITHUB_PRIVATE_KEY_BASE64") + print() + print("=" * 72) + + +def main(): + parser = argparse.ArgumentParser( + description="Complete GitHub App manifest registration" + ) + parser.add_argument("--code", required=True, help="Temporary code from GitHub redirect") + args = parser.parse_args() + + creds = exchange_code(args.code.strip()) + output = build_output(creds) + saved_path = save_credentials(output) + print_safe_summary(output) + + +if __name__ == "__main__": + main() diff --git a/scripts/create_github_app_manifest.py b/scripts/create_github_app_manifest.py new file mode 100644 index 0000000..340caf8 --- /dev/null +++ b/scripts/create_github_app_manifest.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Generate a GitHub App manifest and print the registration URL. + +Usage: + python scripts/create_github_app_manifest.py + python scripts/create_github_app_manifest.py --org MyOrg + python scripts/create_github_app_manifest.py --help +""" +import argparse +import json +import os +import urllib.parse + +REPO_URL = "https://github.com/OMCHOKSI108/codesec-audit-dataset" + +DEFAULTS = { + "name": "CodeSecAudit AI", + "url": os.environ.get("PUBLIC_WEBSITE_URL") or os.environ.get("API_BASE_URL") or REPO_URL, + "callback_url": os.environ.get("GITHUB_CALLBACK_URL", ""), + "webhook_url": os.environ.get("GITHUB_WEBHOOK_URL", ""), +} + +PERMISSIONS = { + "contents": "read", + "pull_requests": "write", + "issues": "write", + "checks": "write", + "metadata": "read", +} + +EVENTS = [ + "pull_request", + "installation", + "installation_repositories", +] + + +def build_manifest(callback_url: str = "", webhook_url: str = "") -> dict: + hook = None + if webhook_url: + hook = {"url": webhook_url, "active": True} + + manifest = { + "name": DEFAULTS["name"], + "url": DEFAULTS["url"], + "public": False, + "default_permissions": PERMISSIONS, + "default_events": EVENTS, + } + + if callback_url: + manifest["redirect_url"] = callback_url + if hook: + manifest["hook_attributes"] = hook + + return manifest + + +def write_manifest(manifest: dict, path: str = ".github-app-manifest.json") -> str: + with open(path, "w") as f: + json.dump(manifest, f, indent=2) + return os.path.abspath(path) + + +def build_register_url(manifest: dict, org: str | None = None) -> str: + encoded = urllib.parse.quote(json.dumps(manifest)) + if org: + return f"https://github.com/organizations/{org}/settings/apps/new?manifest={encoded}" + return f"https://github.com/settings/apps/new?manifest={encoded}" + + +def main(): + parser = argparse.ArgumentParser( + description="Generate GitHub App manifest for CodeSecAudit AI" + ) + parser.add_argument("--org", help="GitHub organization name (for org-level app)") + parser.add_argument( + "--webhook-url", + default=DEFAULTS["webhook_url"], + help=f"Webhook URL (default: GITHUB_WEBHOOK_URL env or empty)", + ) + parser.add_argument( + "--callback-url", + default=DEFAULTS["callback_url"], + help=f"OAuth callback URL (default: GITHUB_CALLBACK_URL env or empty)", + ) + parser.add_argument( + "--output", + default=".github-app-manifest.json", + help="Output path for manifest JSON (default: .github-app-manifest.json)", + ) + + args = parser.parse_args() + + manifest = build_manifest( + callback_url=args.callback_url, + webhook_url=args.webhook_url, + ) + + out_path = write_manifest(manifest, args.output) + reg_url = build_register_url(manifest, org=args.org) + + print("=" * 72) + print(" CodeSecAudit AI — GitHub App Manifest") + print("=" * 72) + print() + print(f" Manifest written: {out_path}") + print() + print(f" Step 1: Open this URL in your browser:") + print(f" {reg_url}") + print() + print(f" Step 2: Click 'Create GitHub App'") + print(f" Step 3: After redirect, copy the 'code' query parameter") + print(f" Step 4: Run:") + print(f' python scripts/complete_github_app_manifest.py --code YOUR_CODE') + print() + print(f" Permissions requested:") + for perm, level in PERMISSIONS.items(): + print(f" - {perm}: {level}") + print() + print(f" Events subscribed:") + for evt in EVENTS: + print(f" - {evt}") + print() + print("=" * 72) + + +if __name__ == "__main__": + main() diff --git a/scripts/verify_github_app_env.py b/scripts/verify_github_app_env.py new file mode 100644 index 0000000..e144cbe --- /dev/null +++ b/scripts/verify_github_app_env.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Verify GitHub App environment variables are set (safe — never prints values). + +Usage: + python scripts/verify_github_app_env.py +""" +import os + +REQUIRED_VARS = [ + "GITHUB_APP_ID", + "GITHUB_APP_SLUG", + "GITHUB_CLIENT_ID", + "GITHUB_CLIENT_SECRET", + "GITHUB_WEBHOOK_SECRET", + "GITHUB_PRIVATE_KEY_BASE64", + "GITHUB_CALLBACK_URL", + "GITHUB_WEBHOOK_URL", +] + + +def main(): + print("=" * 72) + print(" GitHub App Environment Check") + print("=" * 72) + print() + print(f" {'Variable':<30} {'Status':<10}") + print(f" {'-'*30} {'-'*10}") + all_present = True + for var in REQUIRED_VARS: + present = bool(os.environ.get(var)) + status = "present" if present else "MISSING" + if not present: + all_present = False + print(f" {var:<30} {status:<10}") + print() + if all_present: + print(" Result: All required vars present") + else: + print(" Result: Some vars are missing") + print(" Run: python scripts/apply_github_app_env.py --write") + print() + print("=" * 72) + + +if __name__ == "__main__": + main()