"""GemmaForge — public-repo scanner on HuggingFace Spaces (Gradio + ZeroGPU). Paste a public GitHub repo URL, get ranked vulnerability leads back. Runs the spanmax-trained linear + per-CWE probes (layer 8) on Gemma 4 E2B activations, sliding window over every supported source file. Sub-τ leads (below the F1-max threshold calibrated on heldout) are kept in the table but greyed out in the viewer — same calibration story as the local dashboard. Two viewers ship in this Space: - **Scan** tab: paste a URL, run a scan, see ranked leads. After the scan finishes, switch to **Last scan** to inspect the leads in the file-level visualizer. - **Heldout benchmark** tab: the precomputed 136-repo SVEN heldout split, scored by the same probe. Browse by CWE/lang, click a repo, see truth spans + probe overlays. """ from __future__ import annotations import json import os import re import shutil import subprocess import tempfile import time from pathlib import Path from typing import Optional import gradio as gr from fastapi.staticfiles import StaticFiles try: import spaces # ZeroGPU runtime helper (no-op locally) except Exception: # pragma: no cover — local dev without the Spaces SDK class _SpacesShim: def GPU(self, *_args, **_kwargs): # noqa: N802 — matches HF API def deco(fn): return fn return deco spaces = _SpacesShim() # type: ignore[assignment] # Vendored modules live next to this file from gemmaforge_core.adapter import GemmaForgeFilter from gemmaforge_core.events import MemoryEventSink from gemmaforge_core.scan import lead_to_record, scan from viewer_routes import ( LiveScan, LiveScanState, StaticBenchmark, build_scan_router, build_static_router, calibration_threshold, load_calibration, ) # -------- limits & defaults --------------------------------------------- # MAX_REPO_BYTES = 100 * 1024 * 1024 MAX_SOURCE_FILES = 400 DEFAULT_MAX_CHUNKS = 800 MAX_CHUNK_CEILING = 2000 TIME_BUDGET_SECONDS = 105 DEFAULT_LANGUAGES = "py,js" DEFAULT_TOP_K = 25 DEFAULT_MIN_CONFIDENCE = 0.5 HERE = Path(__file__).parent PROBE_BIN_PATH = str(HERE / "probes" / "probe_spanmax.npz") PROBE_CWE_PATH = str(HERE / "probes" / "probe_per_cwe_sven.npz") CALIBRATION_PATH = HERE / "probes" / "probe_spanmax_f1.json" BENCHMARK_ROOT = HERE / "data" / "repo_benchmark_heldout" REPORT_PATH = HERE / "data" / "eval" / "repo_scan_report_spanmax.json" STATIC_DIR = HERE / "static" MODEL_ID = os.environ.get("GEMMAFORGE_MODEL_ID", "google/gemma-4-E2B-it") # Eagerly loaded probe (tiny). Heavy model load deferred until first GPU call. _FILTER: Optional[GemmaForgeFilter] = None _LIVE_SCAN = LiveScanState() _STATIC_BENCH = StaticBenchmark( benchmark_root=BENCHMARK_ROOT, leads_dir_name="leads_spanmax", report_path=REPORT_PATH, calibration_path=CALIBRATION_PATH, ) _CALIBRATION = load_calibration(CALIBRATION_PATH) _TAU = calibration_threshold(_CALIBRATION) def _get_filter() -> GemmaForgeFilter: global _FILTER if _FILTER is None: _FILTER = GemmaForgeFilter( probe_path=PROBE_BIN_PATH, per_cwe_probe_path=PROBE_CWE_PATH, model_id=MODEL_ID, max_tokens=512, token_stride=256, scan_profile="spanmax", ) return _FILTER # -------- URL parsing & safe clone -------------------------------------- # _GITHUB_FULL_RE = re.compile( r"^https?://github\.com/(?P[\w.\-]+)/(?P[\w.\-]+?)(?:\.git)?/?$", re.IGNORECASE, ) _GITHUB_SHORT_RE = re.compile(r"^(?P[\w.\-]+)/(?P[\w.\-]+)$") def normalize_github_url(raw: str) -> str: s = raw.strip() if not s: raise ValueError("Please paste a public GitHub repo URL.") m = _GITHUB_FULL_RE.match(s) if m: owner, repo = m.group("owner"), m.group("repo") else: m = _GITHUB_SHORT_RE.match(s) if not m: raise ValueError( "URL must look like `owner/repo` or `https://github.com/owner/repo`." ) owner, repo = m.group("owner"), m.group("repo") if owner.startswith(".") or repo.startswith("."): raise ValueError("Invalid owner/repo name.") return f"https://github.com/{owner}/{repo}.git" def _repo_id_from_url(url: str) -> str: m = _GITHUB_FULL_RE.match(url) if not m: return "scan" return f"{m.group('owner')}--{m.group('repo')}" def clone_shallow(url: str, dest: Path) -> None: cmd = [ "git", "clone", "--depth", "1", "--single-branch", "--quiet", url, str(dest), ] proc = subprocess.run( cmd, capture_output=True, text=True, timeout=60, env={**os.environ, "GIT_TERMINAL_PROMPT": "0"}, ) if proc.returncode != 0: msg = (proc.stderr or proc.stdout or "git clone failed").strip().splitlines() first = msg[0] if msg else "git clone failed" raise RuntimeError(f"Clone failed: {first}") def dir_size_bytes(root: Path) -> int: total = 0 for p in root.rglob("*"): if p.is_file(): try: total += p.stat().st_size except OSError: pass return total # -------- scan harness with hard caps ----------------------------------- # class CappedFilter: """Wraps GemmaForgeFilter with a chunk cap and a wall-clock budget.""" def __init__(self, inner: GemmaForgeFilter, max_calls: int, deadline_at: float) -> None: self.inner = inner self.max_calls = max_calls self.deadline_at = deadline_at self.calls = 0 self.skipped = 0 self.stop_reason: Optional[str] = None self.model_id = inner.model_id self._layer = inner._layer # noqa: SLF001 self.scan_profile = inner.scan_profile self.max_tokens = inner.max_tokens self.token_stride = inner.token_stride def _stub_score(self) -> dict: return { "gemmaforge_confidence": 0.0, "gemmaforge_confidence_robust": 0.0, "gemmaforge_top_cwe": None, "gemmaforge_top_cwe_score": None, "gemmaforge_cwe_ranking": [], "gemmaforge_layer": self._layer, "model_id": self.model_id, "scan_profile": self.scan_profile, "scoring_mode": "skipped", "token_count": 0, "windows_scored": 0, "max_tokens": self.max_tokens, "token_stride": self.token_stride, } def score_snippet(self, snippet: str) -> dict: if self.stop_reason is not None: self.skipped += 1 return self._stub_score() if self.calls >= self.max_calls: self.stop_reason = "max_chunks" self.skipped += 1 return self._stub_score() if time.time() >= self.deadline_at: self.stop_reason = "time_budget" self.skipped += 1 return self._stub_score() self.calls += 1 return self.inner.score_snippet(snippet) def _trim_file_list(root: Path, languages: set[str], cap: int) -> list[Path]: from gemmaforge_core.scan import LANG_BY_EXT, SKIP_DIRS picked: list[Path] = [] for path in sorted(root.rglob("*")): if len(picked) >= cap: break if not path.is_file(): continue if any(part in SKIP_DIRS for part in path.relative_to(root).parts): continue ext = path.suffix.lower() lang = LANG_BY_EXT.get(ext) if lang is None or lang not in languages: continue try: if path.stat().st_size > 256 * 1024: continue except OSError: continue picked.append(path) return picked def _prune_to_files(root: Path, keep: list[Path]) -> None: keep_set = {p.resolve() for p in keep} for path in root.rglob("*"): if path.is_file() and path.resolve() not in keep_set: try: path.unlink() except OSError: pass def _retire_previous_scan() -> None: """Free the disk used by the previous scan's checkout (if any).""" prev = _LIVE_SCAN.get() if prev is None: return try: if prev.repo_dir.exists(): shutil.rmtree(prev.repo_dir.parent, ignore_errors=True) except OSError: pass # -------- ZeroGPU entry point ------------------------------------------- # @spaces.GPU(duration=120) def run_scan( repo_url: str, languages: str, top_k: int, min_confidence: float, max_chunks: int, ) -> tuple[str, list[list[str]], Optional[str]]: """Clone + scan. Returns (status_md, leads_rows, jsonl_path).""" t0 = time.time() url = normalize_github_url(repo_url) lang_set = {tag.strip() for tag in (languages or DEFAULT_LANGUAGES).split(",") if tag.strip()} chunk_cap = max(50, min(int(max_chunks or DEFAULT_MAX_CHUNKS), MAX_CHUNK_CEILING)) # Persist the workdir across run_scan() so the viewer can serve files # from it. Cleared on the *next* scan via _retire_previous_scan(). tmpdir = Path(tempfile.mkdtemp(prefix="gemmaforge-scan-")) repo_dir = tmpdir / "repo" clone_shallow(url, repo_dir) size = dir_size_bytes(repo_dir) if size > MAX_REPO_BYTES: shutil.rmtree(tmpdir, ignore_errors=True) raise RuntimeError( f"Repo is {size / (1024*1024):.1f} MB after shallow clone — " f"this Space caps at {MAX_REPO_BYTES // (1024*1024)} MB. " f"Try a smaller repo or scan locally." ) files = _trim_file_list(repo_dir, lang_set, MAX_SOURCE_FILES) if not files: shutil.rmtree(tmpdir, ignore_errors=True) raise RuntimeError( f"No source files matching `{','.join(sorted(lang_set))}` found in the repo." ) _prune_to_files(repo_dir, files) deadline_at = t0 + TIME_BUDGET_SECONDS gf = CappedFilter(_get_filter(), max_calls=chunk_cap, deadline_at=deadline_at) events = MemoryEventSink() leads = list(scan( repo_dir, gf=gf, # type: ignore[arg-type] languages=lang_set, window=30, stride=20, min_confidence=float(min_confidence), top_k=int(top_k) or None, events=events, )) rows: list[list[str]] = [] records: list[dict] = [] for rank, lead in enumerate(leads): rec = lead_to_record(lead, rank) records.append(rec) snippet_lines = (lead.chunk.text or "").strip().splitlines() preview = (snippet_lines[0][:120] + "…") if snippet_lines and len(snippet_lines[0]) > 120 else (snippet_lines[0] if snippet_lines else "") marker = "" if lead.confidence >= _TAU else "·" # below-τ marker rows.append([ f"{marker}{rank + 1}".strip(), f"{lead.chunk.file}:{lead.chunk.start_line}-{lead.chunk.end_line}", lead.top_cwe or "—", f"{lead.confidence:.3f}", "≥τ" if lead.confidence >= _TAU else "below τ", preview, ]) jsonl_path = tmpdir / "leads.jsonl" with jsonl_path.open("w", encoding="utf-8") as fp: for rec in records: fp.write(json.dumps(rec, ensure_ascii=False) + "\n") files_seen = sum(1 for e in events.events if e["kind"] == "scan.file") total_chunks = gf.calls + gf.skipped elapsed = time.time() - t0 leads_above_tau = sum(1 for l in leads if l.confidence >= _TAU) status = ( f"**Scanned `{url}`** — " f"{files_seen} file(s), {gf.calls}/{total_chunks} chunk(s) scored, " f"{len(leads)} lead(s) ({leads_above_tau} ≥τ={_TAU:.3f}) in {elapsed:.1f}s. " f"Probe layer {gf._layer}, model `{gf.model_id}`." ) if gf.stop_reason == "max_chunks": status += ( f"\n\n⚠️ Hit the {chunk_cap}-chunk cap — {gf.skipped} chunk(s) skipped. " f"Bump the slider (max {MAX_CHUNK_CEILING}) or scan locally for a full sweep." ) elif gf.stop_reason == "time_budget": status += ( f"\n\n⚠️ Hit the {TIME_BUDGET_SECONDS}s time budget — {gf.skipped} chunk(s) skipped. " f"Lower top-K / tighten languages, or scan locally." ) # Hand off to the viewer state. Clean up the *previous* scan workdir # first so we never carry more than one historical checkout. _retire_previous_scan() _LIVE_SCAN.set(LiveScan( repo_id=_repo_id_from_url(url), repo_url=url, repo_dir=repo_dir, files=[{"path": str(p.relative_to(repo_dir)), "role": ""} for p in files], leads=records, cwe=None, language=None, )) status += "\n\nOpen the **Last scan** tab to inspect the leads in the file viewer." return status, rows, str(jsonl_path) # -------- Gradio UI ----------------------------------------------------- # EXAMPLE_REPOS = [ "expressjs/express", "psf/requests", "pallets/flask", ] HEADER_MD = """ # GemmaForge — public-repo vulnerability scan Paste any public GitHub repo. We shallow-clone it, sweep the source files with the spanmax-trained linear + per-CWE probes on Gemma 4 E2B layer-8 activations, and rank the regions that look most CVE-shaped. **Calibrated thresholding.** Each lead has a confidence in [0, 1]; the *F1-max* threshold τ on the heldout calibration set is **{tau:.3f}** (P={prec:.2f}, R={rec:.2f}, F1={f1:.2f}, AUC={auc:.2f}). Sub-τ leads are kept in the table for inspection but are *not* painted on the file viewer — the probe didn't really fire there. Runs on ZeroGPU (H200-backed). Caps: **{files} files** / **{chunks} chunks** default (max {ceiling}) / **{mb} MB** / **{secs}s wall**. Full sweep: clone [the repo](https://github.com/peaktwilight/gemmaforge) and run `python -m src.scan`. """.format( tau=_TAU, prec=(_CALIBRATION.get("f1_max") or {}).get("precision", 0.0), rec=(_CALIBRATION.get("f1_max") or {}).get("recall", 0.0), f1=(_CALIBRATION.get("f1_max") or {}).get("f1", 0.0), auc=_CALIBRATION.get("auc", 0.0), files=MAX_SOURCE_FILES, chunks=DEFAULT_MAX_CHUNKS, ceiling=MAX_CHUNK_CEILING, mb=MAX_REPO_BYTES // (1024 * 1024), secs=TIME_BUDGET_SECONDS, ) IFRAME_TEMPLATE = """ """ with gr.Blocks(title="GemmaForge — repo scan", theme=gr.themes.Soft()) as demo: gr.Markdown(HEADER_MD) with gr.Tabs(): with gr.Tab("Scan"): with gr.Row(): with gr.Column(scale=3): repo_url = gr.Textbox( label="Public GitHub repo", placeholder="owner/repo — or https://github.com/owner/repo", value="expressjs/express", ) with gr.Column(scale=1): languages = gr.Textbox( label="Languages (comma-sep)", value=DEFAULT_LANGUAGES, info="Subset of: py, js, ts, c, cpp", ) with gr.Row(): top_k = gr.Slider(1, 50, value=DEFAULT_TOP_K, step=1, label="Top-K leads") min_conf = gr.Slider(0.0, 0.95, value=DEFAULT_MIN_CONFIDENCE, step=0.05, label="Min probe confidence") max_chunks = gr.Slider( 50, MAX_CHUNK_CEILING, value=DEFAULT_MAX_CHUNKS, step=50, label="Max chunks", info="Probe forward-pass budget. Higher → deeper coverage, longer scan.", ) run_btn = gr.Button("Scan repo", variant="primary") gr.Examples( examples=[[repo] for repo in EXAMPLE_REPOS], inputs=[repo_url], label="Try a small open-source repo", ) status_md = gr.Markdown() leads_table = gr.Dataframe( headers=["#", "Location", "Top CWE", "Confidence", "vs τ", "Preview"], datatype=["str", "str", "str", "str", "str", "str"], wrap=True, label="Ranked leads (gemmaforge.leads/v1) — sub-τ rows marked '·'", ) jsonl_file = gr.File(label="Download leads.jsonl", interactive=False) run_btn.click( fn=run_scan, inputs=[repo_url, languages, top_k, min_conf, max_chunks], outputs=[status_md, leads_table, jsonl_file], ) with gr.Tab("Last scan"): gr.Markdown( "File-level viewer over the most recent scan. Click a file to " "see lead overlays. Below-τ leads are listed in the bottom " "table greyed out but not painted on the code." ) gr.HTML(IFRAME_TEMPLATE.format(src="/gradio_api/viewer/scan.html")) with gr.Tab("Heldout benchmark"): gr.Markdown( "Pre-computed scan of 136 SVEN heldout microrepos. Each repo " "has one vulnerable file + a fixed counterpart + a few safe " "decoys with line-level ground truth. Same probe, same τ." ) gr.HTML(IFRAME_TEMPLATE.format(src="/gradio_api/viewer/repos.html")) gr.Markdown( "Caveats: probes classify *representation*, not exploitability. " "Spanmax probe at F1-max τ: P=49%, R=35% on heldout — high-precision " "above τ, but expect FPs. Full eval suite: " "[`RESULTS.md`](https://github.com/peaktwilight/gemmaforge/blob/main/RESULTS.md)." ) # -------- Custom FastAPI routes mounted onto Gradio's own app ----------- # # # HF Spaces with SDK=gradio + ZeroGPU runs the script and expects # ``demo.launch()`` to bind the port itself. Running our own uvicorn collides # with the Spaces runtime (the port is already claimed by the time we try to # bind). The portable workaround is to inject routes/mounts into the FastAPI # instance Gradio builds during ``launch()`` via the ``App.create_app`` # constructor, so /api/* and /static/* live on the same server Gradio runs. def _install_custom_routes() -> None: """Inject /api/* + /viewer/* into Gradio's own FastAPI app. Gradio defines a catch-all ``/{path:path}`` route to serve its SvelteKit frontend; FastAPI matches routes in declaration order, so any route we add *after* gradio is shadowed. The fix: prepend our routes/mount to ``app.router.routes`` so they match first. """ from starlette.routing import Mount original_create_app = gr.routes.App.create_app def patched_create_app(*args, **kwargs): app = original_create_app(*args, **kwargs) static_router = build_static_router(_STATIC_BENCH) scan_router = build_scan_router(_LIVE_SCAN, CALIBRATION_PATH) @app.get("/gradio_api/healthz") def _healthz() -> dict: return {"ok": True, "probe": Path(PROBE_BIN_PATH).name, "tau": _TAU} added: list = [] added.extend(static_router.routes) added.extend(scan_router.routes) healthz_route = app.router.routes.pop() added.append(healthz_route) if STATIC_DIR.is_dir(): # Mount under /gradio_api/viewer so HF Spaces' edge proxy passes # asset requests straight through (anything outside that prefix # gets rewritten to the SvelteKit shell at /). added.insert(0, Mount("/gradio_api/viewer", app=StaticFiles(directory=STATIC_DIR), name="viewer")) app.router.routes = added + app.router.routes return app gr.routes.App.create_app = patched_create_app # type: ignore[method-assign] _install_custom_routes() if __name__ == "__main__": demo.queue(default_concurrency_limit=1).launch( server_name="0.0.0.0", server_port=int(os.environ.get("PORT", "7860")), )