From 9158d71e7982e4a7a5d9812e98e9588053d9ceb0 Mon Sep 17 00:00:00 2001 From: Matthew Grange Date: Fri, 27 Mar 2026 13:29:43 -0700 Subject: [PATCH] =?UTF-8?q?Add=20web=20playground=20=E2=80=94=20in-browser?= =?UTF-8?q?=20privacy=20analysis=20via=20Pyodide=20(#122)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary: ## Problem PrivacyGuard is an OSS privacy auditing library, but users need to install Python + dependencies to try it. There's no interactive way to explore the attacks and analyses without setting up a local environment. ## Solution A React + Vite single-page app that runs PrivacyGuard's actual Python analysis code in the browser via Pyodide (CPython compiled to WebAssembly). No server, no backend — user data never leaves the browser. ## What's Included (Stage 1 MVP) **Web UI** (`privacy_guard/github/web/`): - Landing page with attack category cards - MIA tab (LiRA, RMIA, Calib sub-tabs) with parameter forms and CSV input - LIA tab with target + calibration model inputs - Text Similarity tab (TextInclusion + EditSimilarity, Probabilistic Memorization) - f-DP Calculator tab (instant epsilon computation) - Code Similarity tab (Coming Soon placeholder) - Results display with summary cards and JSON/CSV export **Pyodide Integration**: - Web Worker runs Pyodide in background thread for UI responsiveness - Bridge layer for async React ↔ Worker communication - Python runners module with entry points for each attack/analysis - Module loader copies PrivacyGuard .py files into static assets at build time (no duplication) **Deployment**: - GitHub Actions workflow (`deploy-web.yml`) deploys to GitHub Pages - Triggers on changes to `web/`, `attacks/`, or `analysis/` - Separate from library CI — web build failures don't block library releases **Design document**: `privacy_guard/docs/plans/2026-03-27-web-playground-design.md` ## What's NOT included (future stages) - Stage 2: torch shim for MIA AnalysisNode (epsilon/AUC/CI), Plotly.js charts - Stage 3: Code Similarity via web-tree-sitter Differential Revision: D98518329 --- .github/workflows/deploy-web.yml | 54 ++++ web/.gitignore | 18 ++ web/index.html | 12 + web/package.json | 34 ++ web/src/App.tsx | 34 ++ web/src/components/DataInput.tsx | 119 +++++++ web/src/components/ExportButton.tsx | 18 ++ web/src/components/ParameterForm.tsx | 103 ++++++ web/src/components/ResultsCard.tsx | 140 +++++++++ web/src/index.css | 453 +++++++++++++++++++++++++++ web/src/main.tsx | 10 + web/src/pyodide/bridge.ts | 74 +++++ web/src/pyodide/python/runners.py | 274 ++++++++++++++++ web/src/pyodide/worker.ts | 169 ++++++++++ web/src/tabs/CodeSimilarityTab.tsx | 52 +++ web/src/tabs/FDPCalculatorTab.tsx | 133 ++++++++ web/src/tabs/LIATab.tsx | 167 ++++++++++ web/src/tabs/Landing.tsx | 67 ++++ web/src/tabs/MIATab.tsx | 379 ++++++++++++++++++++++ web/src/tabs/TextSimilarityTab.tsx | 224 +++++++++++++ web/src/utils/export.ts | 24 ++ web/src/vite-env.d.ts | 1 + web/tsconfig.app.json | 25 ++ web/tsconfig.json | 7 + web/tsconfig.node.json | 24 ++ web/vite.config.ts | 58 ++++ 26 files changed, 2673 insertions(+) create mode 100644 .github/workflows/deploy-web.yml create mode 100644 web/.gitignore create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/src/App.tsx create mode 100644 web/src/components/DataInput.tsx create mode 100644 web/src/components/ExportButton.tsx create mode 100644 web/src/components/ParameterForm.tsx create mode 100644 web/src/components/ResultsCard.tsx create mode 100644 web/src/index.css create mode 100644 web/src/main.tsx create mode 100644 web/src/pyodide/bridge.ts create mode 100644 web/src/pyodide/python/runners.py create mode 100644 web/src/pyodide/worker.ts create mode 100644 web/src/tabs/CodeSimilarityTab.tsx create mode 100644 web/src/tabs/FDPCalculatorTab.tsx create mode 100644 web/src/tabs/LIATab.tsx create mode 100644 web/src/tabs/Landing.tsx create mode 100644 web/src/tabs/MIATab.tsx create mode 100644 web/src/tabs/TextSimilarityTab.tsx create mode 100644 web/src/utils/export.ts create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.app.json create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts diff --git a/.github/workflows/deploy-web.yml b/.github/workflows/deploy-web.yml new file mode 100644 index 0000000..6c9e2a9 --- /dev/null +++ b/.github/workflows/deploy-web.yml @@ -0,0 +1,54 @@ +name: Deploy Web Playground + +on: + push: + branches: [ main ] + paths: + - 'web/**' + - 'attacks/**' + - 'analysis/**' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + name: Build web playground + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: web/package-lock.json + - name: Install dependencies + run: npm ci + working-directory: web + - name: Build + run: npm run build + working-directory: web + - name: Upload pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: web/dist + + deploy: + name: Deploy to GitHub Pages + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..5ccae52 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,18 @@ +# Dependencies +node_modules + +# Build output +dist + +# Generated at build time by vite.config.ts +public/pg_modules + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +*.swp +*.swo +*~ + +# OS files +.DS_Store diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..e0f738b --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + PrivacyGuard Playground + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..86055e6 --- /dev/null +++ b/web/package.json @@ -0,0 +1,34 @@ +{ + "name": "privacyguard-web", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "plotly.js-dist-min": "^2.35.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-plotly.js": "^2.6.0", + "react-router-dom": "^7.1.0" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@types/react-plotly.js": "^2.6.0", + "@vitejs/plugin-react": "^4.3.0", + "eslint": "^9.17.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.16", + "globals": "^15.14.0", + "@types/node": "^22.0.0", + "typescript": "~5.7.0", + "typescript-eslint": "^8.18.2", + "vite": "^6.0.0" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..8f6ef82 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,34 @@ +import { BrowserRouter, Routes, Route, NavLink } from "react-router-dom"; +import Landing from "./tabs/Landing"; +import MIATab from "./tabs/MIATab"; +import LIATab from "./tabs/LIATab"; +import TextSimilarityTab from "./tabs/TextSimilarityTab"; +import CodeSimilarityTab from "./tabs/CodeSimilarityTab"; +import FDPCalculatorTab from "./tabs/FDPCalculatorTab"; + +function App() { + return ( + + +
+ + } /> + } /> + } /> + } /> + } /> + } /> + +
+
+ ); +} + +export default App; diff --git a/web/src/components/DataInput.tsx b/web/src/components/DataInput.tsx new file mode 100644 index 0000000..cd95a70 --- /dev/null +++ b/web/src/components/DataInput.tsx @@ -0,0 +1,119 @@ +import { useState, useRef } from "react"; + +interface DataInputProps { + format: "csv" | "jsonl"; + requiredColumns: string[]; + placeholder: string; + onData: (data: string) => void; +} + +function DataInput({ format, requiredColumns, placeholder, onData }: DataInputProps) { + const [text, setText] = useState(""); + const [validationError, setValidationError] = useState(null); + const fileInputRef = useRef(null); + + const validate = (data: string): string | null => { + const trimmed = data.trim(); + if (!trimmed) { + return "No data provided."; + } + + if (format === "csv") { + const firstLine = trimmed.split("\n")[0]; + const headers = firstLine.split(",").map((h) => h.trim()); + const missing = requiredColumns.filter((col) => !headers.includes(col)); + if (missing.length > 0) { + return `CSV is missing required column(s): ${missing.join(", ")}. Found headers: ${headers.join(", ")}`; + } + } else { + // jsonl — validate the first line + const firstLine = trimmed.split("\n")[0]; + try { + const obj = JSON.parse(firstLine); + const keys = Object.keys(obj); + const missing = requiredColumns.filter((col) => !keys.includes(col)); + if (missing.length > 0) { + return `JSONL first line is missing required field(s): ${missing.join(", ")}. Found fields: ${keys.join(", ")}`; + } + } catch { + return "First line is not valid JSON. Expected JSONL format (one JSON object per line)."; + } + } + + return null; + }; + + const handleLoad = () => { + const err = validate(text); + if (err) { + setValidationError(err); + return; + } + setValidationError(null); + onData(text.trim()); + }; + + const handleFileUpload = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + const content = event.target?.result; + if (typeof content === "string") { + setText(content); + setValidationError(null); + } + }; + reader.readAsText(file); + + // Reset file input so the same file can be re-selected + if (fileInputRef.current) { + fileInputRef.current.value = ""; + } + }; + + return ( +
+
+ + + + Required {format === "csv" ? "columns" : "fields"}:{" "} + {requiredColumns.join(", ")} + +
+ +