diff --git a/.gitignore b/.gitignore index 6bdbf1c..bc2bf03 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ out/ *.tsbuildinfo next-env.d.ts .vercel +.env \ No newline at end of file diff --git a/app/api/analyze/prompt.txt b/app/api/analyze/prompt.txt new file mode 100644 index 0000000..a6be200 --- /dev/null +++ b/app/api/analyze/prompt.txt @@ -0,0 +1,69 @@ +You are a security analyst who deeply understands how AI coding agents behave when given access to a repository. Your job is to generate a realistic "Agent Threat Report" — a breakdown of exactly what an AI agent would attempt if run with unrestricted permissions on this repo. + +AI agents (Claude Code, Cursor, Copilot, Cline, Aider, etc.) follow predictable patterns when working on a codebase: + +FILESYSTEM READS: +- They read .env, .env.local, .env.production, .env.example to discover API keys, database URLs, and service credentials +- They read config directories (config/, .github/, .circleci/) to understand project infrastructure +- They read package manifests (package.json, requirements.txt, go.mod, Cargo.toml) to understand dependencies +- They read SSH config (~/.ssh/config) and git config (~/.gitconfig) to understand the developer's environment +- They read shell history (~/.bash_history, ~/.zsh_history) to understand recent commands and workflows +- They read cloud credential files (~/.aws/credentials, ~/.config/gcloud/) for deployment context +- They scan broadly through directories to "understand the codebase" — touching far more files than necessary + +FILESYSTEM WRITES: +- They write freely across the project directory, modifying any file they think is relevant +- They can modify shell startup files (.bashrc, .zshrc, .profile) to persist changes +- They can modify git hooks (.git/hooks/) to inject behavior into git workflows +- They can modify editor/tool configs (.vscode/, .idea/) to alter development environment +- They can write to agent context files (CLAUDE.md, .cursorrules) to influence future agent sessions + +COMMAND EXECUTION: +- They run package install commands (npm install, pip install) which execute arbitrary post-install scripts — a major supply-chain attack vector +- They run build commands (make, npm run build) that can trigger arbitrary code +- They run test commands that may hit live services +- They chain commands with && and | pipes, making it hard to audit what actually executes +- They invoke nested shells (bash -c "...") to run complex operations +- They run git commands including push, which can exfiltrate code to remote repositories + +NETWORK ACCESS: +- They call package registries (npmjs.org, pypi.org, crates.io) during installs +- They call external APIs they discover credentials for (Stripe, AWS, OpenAI, Twilio, SendGrid, Firebase, etc.) +- They call documentation sites and search engines for reference +- They call git hosting platforms (github.com, gitlab.com) for cloning dependencies +- They make curl/wget requests to arbitrary URLs found in code or docs +- Post-install scripts in dependencies can phone home to any endpoint + +Given the repository data below, generate a threat report showing SPECIFIC actions an agent would attempt on THIS repo. Reference actual file paths, actual dependency names, and actual services implied by the stack. + +Repository: {{owner}}/{{repo}} +Files (sample): {{files}} +Stack detected: {{stack}} +Dependencies: {{dependencies}} +Sensitive files found: {{sensitiveFiles}} +Config files found: {{configFiles}} + +Respond with ONLY valid JSON (no markdown, no code fences, no explanation): +{ + "riskScore": , + "riskLevel": "LOW" | "MEDIUM" | "HIGH" | "CRITICAL", + "summary": "<2 sentence summary — lead with the scariest finding, then the overall exposure>", + "findings": [ + { + "type": "credential_read" | "network_call" | "directory_access" | "command_execution", + "severity": "low" | "medium" | "high" | "critical", + "title": "", + "description": "<1-2 sentences: what the agent would do, referencing actual files/deps from this repo, and the real-world damage>", + "command": "" + } + ] +} + +Rules: +- Generate 6-8 findings, ordered by severity (critical first) +- Every finding MUST reference actual file paths or dependency names from this specific repo +- Commands must be realistic — use actual file paths found in the tree +- Be generous with risk scores — most repos with any credentials or cloud dependencies should score 60+ +- For repos with .env files AND cloud SDK dependencies, score 80+ +- The summary should make a developer immediately want to install a sandbox +- Do NOT generate generic findings — every finding must be grounded in this repo's actual contents diff --git a/app/api/analyze/route.ts b/app/api/analyze/route.ts new file mode 100644 index 0000000..6fecd06 --- /dev/null +++ b/app/api/analyze/route.ts @@ -0,0 +1,77 @@ +import { NextRequest, NextResponse } from 'next/server' +import { readFileSync } from 'fs' +import { join } from 'path' + +const promptTemplate = readFileSync(join(process.cwd(), 'app/api/analyze/prompt.txt'), 'utf-8') + +// In-memory cache: persists for the lifetime of the server process +const cache = new Map() +const CACHE_TTL = 1000 * 60 * 60 * 24 // 24 hours + +export async function POST(req: NextRequest) { + try { + const { owner, repo, files, stack, dependencies, sensitiveFiles, configFiles } = await req.json() + const baseUrl = process.env.SHARED_LLM_BASE_URL + const apiKey = process.env.SHARED_LLM_API_KEY + + if (!baseUrl || !apiKey) { + return NextResponse.json({ error: 'LLM service not configured' }, { status: 500 }) + } + + // Check cache + const cacheKey = `${owner}/${repo}`.toLowerCase() + const cached = cache.get(cacheKey) + if (cached && Date.now() - cached.timestamp < CACHE_TTL) { + return NextResponse.json(cached.data) + } + + const prompt = promptTemplate + .replace('{{owner}}', owner) + .replace('{{repo}}', repo) + .replace('{{files}}', files.slice(0, 80).join(', ')) + .replace('{{stack}}', stack.join(', ') || 'Unknown') + .replace('{{dependencies}}', Object.keys(dependencies || {}).slice(0, 40).join(', ') || 'None detected') + .replace('{{sensitiveFiles}}', sensitiveFiles.join(', ') || 'None') + .replace('{{configFiles}}', configFiles.join(', ') || 'None') + + let endpoint = baseUrl.replace(/\/+$/, '') + endpoint = endpoint.replace(/\/v1$/, '') + endpoint += '/v1/chat/completions' + + const llmRes = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` }, + body: JSON.stringify({ + model: process.env.SHARED_LLM_MODEL || 'Kimi-K2.5-sandbox', + messages: [{ role: 'user', content: prompt }], + max_tokens: 8000, + temperature: 0.7, + }), + }) + + if (!llmRes.ok) { + console.error('LLM error:', await llmRes.text()) + return NextResponse.json({ error: 'Failed to generate report' }, { status: 500 }) + } + + const data = await llmRes.json() + const msg = data.choices?.[0]?.message + const content = msg?.content || msg?.reasoning_content || '' + if (!content) { + console.error('LLM returned empty content:', JSON.stringify(data).slice(0, 500)) + return NextResponse.json({ error: 'LLM returned empty response' }, { status: 500 }) + } + const jsonMatch = content.match(/\{[\s\S]*\}/) + if (!jsonMatch) { + console.error('No JSON found in LLM response:', content.slice(0, 500)) + return NextResponse.json({ error: 'Failed to parse report' }, { status: 500 }) + } + + const report = JSON.parse(jsonMatch[0]) + cache.set(cacheKey, { data: report, timestamp: Date.now() }) + return NextResponse.json(report) + } catch (e: any) { + console.error('Analyze error:', e) + return NextResponse.json({ error: e.message || 'Analysis failed' }, { status: 500 }) + } +} diff --git a/app/greyscan/layout.tsx b/app/greyscan/layout.tsx new file mode 100644 index 0000000..2f7c9b2 --- /dev/null +++ b/app/greyscan/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next' + +export const metadata: Metadata = { + title: 'Greyscan | Greywall', + description: 'Scan your repo and see what an unrestricted AI agent would attempt. Powered by Greywall.', +} + +export default function ExposureLayout({ children }: { children: React.ReactNode }) { + return children +} diff --git a/app/greyscan/page.tsx b/app/greyscan/page.tsx new file mode 100644 index 0000000..94cb40b --- /dev/null +++ b/app/greyscan/page.tsx @@ -0,0 +1,614 @@ +'use client' + +import { useState, useEffect, useRef } from 'react' +import { + Shield, AlertTriangle, Globe, FolderOpen, Terminal, + ArrowLeft, Copy, Check, ArrowRight, Lock, Eye, +} from 'lucide-react' + +// --- Types --- + +type Phase = 'input' | 'scanning' | 'report' + +interface ScanLine { + id: number + text: string + type: 'info' | 'warning' | 'success' | 'error' +} + +interface Finding { + type: string + severity: string + title: string + description: string + command: string +} + +interface ThreatReport { + riskScore: number + riskLevel: string + summary: string + findings: Finding[] +} + +// --- Constants --- + +const SENSITIVE_PATTERNS = [ + '.env', '.env.local', '.env.production', '.env.development', + '.env.example', '.env.sample', '.env.test', + 'secrets.yml', 'secrets.yaml', 'credentials.json', + 'serviceAccountKey.json', '.npmrc', '.pypirc', + 'id_rsa', '.pem', '.key', '.p12', + 'docker-compose.yml', 'docker-compose.yaml', 'Dockerfile', + '.htpasswd', +] + +const CONFIG_PATTERNS = [ + 'config/', '.github/', '.circleci/', '.gitlab-ci.yml', + 'webpack.config', 'vite.config', 'next.config', 'nuxt.config', + 'tsconfig.json', 'nginx.conf', 'Procfile', 'vercel.json', + 'netlify.toml', 'terraform/', 'k8s/', '.aws/', 'Makefile', +] + +const STACK_DETECT: [string, (f: string[]) => boolean][] = [ + ['Node.js', f => f.some(x => x === 'package.json')], + ['TypeScript', f => f.some(x => x === 'tsconfig.json' || x.endsWith('.ts') || x.endsWith('.tsx'))], + ['Python', f => f.some(x => ['requirements.txt', 'setup.py', 'pyproject.toml', 'Pipfile'].includes(x))], + ['Go', f => f.some(x => x === 'go.mod')], + ['Rust', f => f.some(x => x === 'Cargo.toml')], + ['Ruby', f => f.some(x => x === 'Gemfile')], + ['Java', f => f.some(x => x === 'pom.xml' || x === 'build.gradle')], + ['React', f => f.some(x => x.endsWith('.jsx') || x.endsWith('.tsx'))], + ['Next.js', f => f.some(x => x.startsWith('next.config'))], + ['Django', f => f.some(x => x === 'manage.py')], + ['Docker', f => f.some(x => x === 'Dockerfile' || x.startsWith('docker-compose'))], + ['Terraform', f => f.some(x => x.endsWith('.tf'))], +] + +// --- Helpers --- + +function parseGitHubUrl(url: string) { + const cleaned = url.trim().replace(/\.git$/, '').replace(/\/$/, '') + const m = cleaned.match(/(?:(?:https?:\/\/)?github\.com\/)?([^\/\s]+)\/([^\/\s]+)/) + if (!m) throw new Error('Invalid GitHub URL. Use format: github.com/owner/repo') + return { owner: m[1], repo: m[2] } +} + +const delay = (ms: number) => new Promise(r => setTimeout(r, ms)) + +async function fetchTree(owner: string, repo: string): Promise<{ files: string[]; truncated: boolean }> { + const meta = await fetch(`https://api.github.com/repos/${owner}/${repo}`) + if (meta.status === 404) throw new Error('Repository not found. Is it public?') + if (meta.status === 403) throw new Error('GitHub rate limit hit. Try again in a minute.') + if (!meta.ok) throw new Error(`GitHub error: ${meta.status}`) + const { default_branch } = await meta.json() + + const tree = await fetch( + `https://api.github.com/repos/${owner}/${repo}/git/trees/${default_branch}?recursive=1` + ) + if (!tree.ok) throw new Error('Failed to fetch repo tree') + const data = await tree.json() + const files = (data.tree || []) + .filter((n: any) => n.type === 'blob') + .map((n: any) => n.path) + return { files, truncated: !!data.truncated } +} + +async function fetchFile(owner: string, repo: string, path: string): Promise { + try { + const res = await fetch(`https://api.github.com/repos/${owner}/${repo}/contents/${path}`) + if (!res.ok) return null + const { content, encoding } = await res.json() + if (encoding === 'base64') return atob(content.replace(/\n/g, '')) + return null + } catch { return null } +} + +function severityColor(s: string) { + if (s === 'critical') return 'text-red-400' + if (s === 'high') return 'text-primary' + if (s === 'medium') return 'text-yellow-400' + return 'text-green-400' +} + +function severityBg(s: string) { + if (s === 'critical') return 'bg-red-400/10 border border-red-400/20' + if (s === 'high') return 'bg-primary/10 border border-primary/20' + if (s === 'medium') return 'bg-yellow-400/10 border border-yellow-400/20' + return 'bg-green-400/10 border border-green-400/20' +} + +function typeIcon(t: string) { + if (t === 'credential_read') return + if (t === 'network_call') return + if (t === 'directory_access') return + if (t === 'command_execution') return + return +} + +function riskColor(level: string) { + if (level === 'CRITICAL') return '#ef4444' + if (level === 'HIGH') return '#D95E2A' + if (level === 'MEDIUM') return '#eab308' + return '#22c55e' +} + +// --- Component --- + +export default function GamePage() { + const [phase, setPhase] = useState('input') + const [url, setUrl] = useState('') + const [lines, setLines] = useState([]) + const [report, setReport] = useState(null) + const [repoName, setRepoName] = useState('') + const [detectedStack, setDetectedStack] = useState([]) + const [error, setError] = useState('') + const [copied, setCopied] = useState(false) + const [animatedScore, setAnimatedScore] = useState(0) + const termRef = useRef(null) + const lineId = useRef(0) + const hasAutoScanned = useRef(false) + + function addLine(text: string, type: ScanLine['type']) { + lineId.current++ + setLines(prev => [...prev, { id: lineId.current, text, type }]) + } + + // Auto-scroll terminal + useEffect(() => { + if (termRef.current) termRef.current.scrollTop = termRef.current.scrollHeight + }, [lines]) + + // Auto-scan from URL param + useEffect(() => { + if (hasAutoScanned.current) return + const params = new URLSearchParams(window.location.search) + const repoParam = params.get('repo') + if (repoParam) { + hasAutoScanned.current = true + const fullUrl = repoParam.includes('github.com') ? repoParam : `https://github.com/${repoParam}` + setUrl(fullUrl) + scan(fullUrl) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Animate score counter + useEffect(() => { + if (!report) return + const target = report.riskScore + const start = Date.now() + const duration = 1500 + function tick() { + const progress = Math.min((Date.now() - start) / duration, 1) + setAnimatedScore(Math.round(target * (1 - Math.pow(1 - progress, 3)))) + if (progress < 1) requestAnimationFrame(tick) + } + requestAnimationFrame(tick) + }, [report]) + + async function scan(inputUrl?: string) { + const targetUrl = inputUrl || url + if (!targetUrl.trim()) return + + setPhase('scanning') + setLines([]) + setError('') + setReport(null) + lineId.current = 0 + + try { + const { owner, repo } = parseGitHubUrl(targetUrl) + setRepoName(`${owner}/${repo}`) + window.history.replaceState(null, '', `${window.location.pathname}?repo=${owner}/${repo}`) + + addLine(`Connecting to github.com/${owner}/${repo}...`, 'info') + await delay(400) + + addLine('Fetching repository tree...', 'info') + const { files, truncated } = await fetchTree(owner, repo) + const dirCount = new Set(files.map(f => f.split('/').slice(0, -1).join('/'))).size + addLine(`Found ${files.length.toLocaleString()} files across ${dirCount} directories`, 'success') + if (truncated) addLine('Large repo — tree was truncated by GitHub', 'info') + await delay(300) + + // Detect stack + addLine('Detecting stack & dependencies...', 'info') + await delay(200) + const stack = STACK_DETECT.filter(([, test]) => test(files)).map(([name]) => name) + setDetectedStack(stack) + addLine(`Stack: ${stack.join(' \u00b7 ') || 'Unknown'}`, 'success') + await delay(300) + + // Sensitive files + addLine('Scanning for sensitive files & secrets...', 'info') + await delay(200) + const sensitive = files.filter(f => { + const name = f.split('/').pop() || f + return SENSITIVE_PATTERNS.some(p => name === p || name.startsWith(p + '.') || f.includes('/' + p)) + }) + for (const f of sensitive.slice(0, 12)) { + addLine(f, 'warning') + await delay(100) + } + if (sensitive.length === 0) addLine('No obvious sensitive files found', 'success') + if (sensitive.length > 12) addLine(`...and ${sensitive.length - 12} more`, 'warning') + await delay(200) + + // Config files + const configs = files.filter(f => CONFIG_PATTERNS.some(p => f.includes(p))).slice(0, 15) + + // Fetch dependencies + addLine('Analyzing dependencies...', 'info') + await delay(200) + let deps: Record = {} + const pkgRaw = await fetchFile(owner, repo, 'package.json') + if (pkgRaw) { + try { + const pkg = JSON.parse(pkgRaw) + deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) } + } catch { /* ignore parse errors */ } + } else { + const reqs = await fetchFile(owner, repo, 'requirements.txt') + if (reqs) { + reqs.split('\n').forEach(l => { + const m = l.trim().match(/^([a-zA-Z0-9_-]+)/) + if (m) deps[m[1]] = '*' + }) + } + } + + // Highlight interesting deps + const interesting = Object.keys(deps).filter(d => + /stripe|aws|openai|anthropic|firebase|supabase|prisma|mongoose|pg|mysql|redis|twilio|sendgrid|auth0|passport|jwt|bcrypt|crypto|axios|ssh|docker|kubernetes|helm|vault|sentry/i.test(d) + ) + for (const d of interesting.slice(0, 8)) { + addLine(`${d} ${deps[d] !== '*' ? deps[d] : ''}`, 'warning') + await delay(80) + } + if (Object.keys(deps).length > 0) { + addLine(`${Object.keys(deps).length} total dependencies`, 'success') + } + await delay(300) + + // Generate report via LLM + addLine('Generating agent threat report...', 'info') + const res = await fetch('/api/analyze', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ owner, repo, files, stack, dependencies: deps, sensitiveFiles: sensitive, configFiles: configs }), + }) + + if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error(data.error || 'Failed to generate report') + } + + const reportData = await res.json() + addLine('Report complete.', 'success') + await delay(600) + + setReport(reportData) + setPhase('report') + } catch (e: any) { + addLine(e.message || 'Something went wrong', 'error') + setError(e.message) + } + } + + function reset() { + setPhase('input') + setUrl('') + setLines([]) + setReport(null) + setError('') + setRepoName('') + setDetectedStack([]) + setAnimatedScore(0) + window.history.replaceState(null, '', window.location.pathname) + } + + async function copyLink() { + await navigator.clipboard.writeText(window.location.href) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + } + + const color = report ? riskColor(report.riskLevel) : '#D95E2A' + const circumference = 2 * Math.PI * 45 + + return ( +
+ {/* Nav */} + + +
+ {/* ── INPUT PHASE ── */} + {phase === 'input' && ( +
+
+
+ +
+

+ What would an AI agent{' '} + try on your repo? +

+ +

+ Paste a public GitHub URL. We'll scan your codebase and show exactly what an unrestricted AI agent would attempt. +

+ +
{ e.preventDefault(); scan() }} + className="flex gap-2 max-w-lg mx-auto" + > + setUrl(e.target.value)} + placeholder="github.com/owner/repo" + className="flex-1 bg-card/50 border border-border/50 rounded-lg px-4 py-3 text-sm font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:border-primary/50 focus:ring-1 focus:ring-primary/20 transition-all" + /> + +
+ +

+ Public repos only · No code is stored · Powered by Greywall +

+
+
+ )} + + {/* ── SCANNING PHASE ── */} + {phase === 'scanning' && ( +
+
+
+ + + Scanning {repoName} + +
+ +
+
+
+
+
+ + greywall scan + +
+ +
+ {lines.map(line => ( +
+ {line.type === 'info' && ( + + $ {line.text} + + )} + {line.type === 'success' && ( + + {'\u2713'} {line.text} + + )} + {line.type === 'warning' && ( + + {'\u26A0'} {line.text} + + )} + {line.type === 'error' && ( + + {'\u2717'} {line.text} + + )} +
+ ))} + {!error && ( +
+ )} +
+
+ + {error && ( +
+ +
+ )} +
+
+ )} + + {/* ── REPORT PHASE ── */} + {phase === 'report' && report && ( +
+
+ {/* Report Card */} +
+ {/* Header */} +
+
+
+
+ + + Agent Threat Report + +
+

+ {repoName} +

+

+ {detectedStack.join(' \u00b7 ')} +

+
+ + {/* Risk Score Circle */} +
+ + + + +
+ + {animatedScore} + + + /100 + +
+
+
+ + {/* Risk Level Badge */} +
+ + {report.riskLevel} RISK +
+
+ + {/* Summary */} +
+

+ {report.summary} +

+
+ + {/* Findings */} +
+ {report.findings.map((finding, i) => ( +
+
+
+ {typeIcon(finding.type)} +
+
+
+ + {finding.severity} + +
+

+ {finding.title} +

+

+ {finding.description} +

+ + {finding.command} + +
+
+
+ ))} +
+ + {/* CTA */} +
+

+ + This is what Greywall would have blocked. + +

+

+ Container-free sandboxing with real-time observability for AI agents. +

+ + Install Greywall + + +
+
+ + {/* Actions */} +
+ + +
+
+
+ )} +
+
+ ) +} diff --git a/components/nav.tsx b/components/nav.tsx index 87b8663..c7515f4 100644 --- a/components/nav.tsx +++ b/components/nav.tsx @@ -36,6 +36,12 @@ export function Nav() { > About + + Greyscan +