Files
greywall-landing-page/app/greyscan/page.tsx
2026-03-18 15:21:56 -04:00

641 lines
27 KiB
TypeScript

'use client'
import { useState, useEffect, useRef } from 'react'
import {
Shield, AlertTriangle, Globe, FolderOpen, Terminal,
ArrowLeft, Copy, Check, ArrowRight, Lock, Eye, MessageSquareWarning,
} 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 AGENT_INSTRUCTION_PATTERNS = [
'CLAUDE.md', '.claude/', '.claude/commands/', '.claude/agents/',
'.cursorrules', '.cursorignore', '.cursor/',
'.github/copilot-instructions.md',
'AGENTS.md', '.mcp.json', 'mcp.json',
'.aider.conf.yml', '.aiderignore',
'CONVENTIONS.md', 'CONTRIBUTING.md',
'.windsurfrules', '.boltrules', '.clinerules',
]
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<string | null> {
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 <Lock className="h-4 w-4" />
if (t === 'network_call') return <Globe className="h-4 w-4" />
if (t === 'directory_access') return <FolderOpen className="h-4 w-4" />
if (t === 'command_execution') return <Terminal className="h-4 w-4" />
if (t === 'prompt_injection') return <MessageSquareWarning className="h-4 w-4" />
return <Eye className="h-4 w-4" />
}
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<Phase>('input')
const [url, setUrl] = useState('')
const [lines, setLines] = useState<ScanLine[]>([])
const [report, setReport] = useState<ThreatReport | null>(null)
const [repoName, setRepoName] = useState('')
const [detectedStack, setDetectedStack] = useState<string[]>([])
const [error, setError] = useState('')
const [copied, setCopied] = useState(false)
const [animatedScore, setAnimatedScore] = useState(0)
const termRef = useRef<HTMLDivElement>(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)
// Agent instruction files (prompt injection surface)
addLine('Scanning for agent instruction files...', 'info')
await delay(200)
const agentFiles = files.filter(f => {
const name = f.split('/').pop() || f
return AGENT_INSTRUCTION_PATTERNS.some(p => name === p || f.includes(p))
})
for (const f of agentFiles.slice(0, 8)) {
addLine(f, 'warning')
await delay(100)
}
if (agentFiles.length === 0) addLine('No agent instruction files found', 'success')
await delay(200)
// Fetch dependencies
addLine('Analyzing dependencies...', 'info')
await delay(200)
let deps: Record<string, string> = {}
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, agentInstructionFiles: agentFiles }),
})
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 (
<main className="min-h-screen">
{/* Nav */}
<nav className="fixed top-0 left-0 right-0 z-50 border-b border-border/50 bg-background/80 backdrop-blur-md">
<div className="mx-auto max-w-5xl flex items-center justify-between px-6 h-14">
<a href="/" className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors">
<ArrowLeft className="h-4 w-4" />
<span className="hidden sm:inline">Back to Greywall</span>
</a>
<div className="flex items-center gap-2.5">
<svg viewBox="0 0 32 32" fill="none" className="h-5 w-5" xmlns="http://www.w3.org/2000/svg">
<path d="M16 2L4 7V15C4 22.18 9.11 28.79 16 30C22.89 28.79 28 22.18 28 15V7L16 2Z" fill="#D95E2A" />
<path d="M16 6L8 9.5V15C8 20.05 11.42 24.68 16 26C20.58 24.68 24 20.05 24 15V9.5L16 6Z" fill="#161614" />
<circle cx="16" cy="12" r="2" fill="#D95E2A" />
<circle cx="12" cy="17" r="1.5" fill="#D95E2A" />
<circle cx="20" cy="17" r="1.5" fill="#D95E2A" />
<circle cx="16" cy="21" r="1.5" fill="#D95E2A" />
<path d="M16 14V19.5M14 16L12.5 17M18 16L19.5 17" stroke="#D95E2A" strokeWidth="1" strokeLinecap="round" />
</svg>
<span className="font-serif font-semibold text-lg tracking-tight">Greyscan</span>
</div>
<div className="w-20" />
</div>
</nav>
<div className="pt-14">
{/* ── INPUT PHASE ── */}
{phase === 'input' && (
<section className="relative min-h-[calc(100vh-3.5rem)] flex items-center justify-center px-4 sm:px-6">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(217,94,42,0.06)_0%,transparent_50%)]" />
<div
className="absolute inset-0 opacity-[0.03]"
style={{
backgroundImage: 'linear-gradient(rgba(249,249,247,1) 1px, transparent 1px), linear-gradient(90deg, rgba(249,249,247,1) 1px, transparent 1px)',
backgroundSize: '64px 64px',
}}
/>
<div className="relative max-w-2xl w-full text-center animate-fade-up">
<h1 className="font-serif text-3xl sm:text-4xl md:text-5xl font-semibold tracking-tight leading-[1.1] mb-4">
What would an AI agent{' '}
<em className="italic text-primary">try on your machine?</em>
</h1>
<p className="text-muted-foreground font-serif text-base sm:text-lg leading-relaxed mb-10 max-w-xl mx-auto">
AI agents run as you, with access to everything you have. Paste a repo and see what an unrestricted agent could attempt. This is not a security audit, it&apos;s a wake-up call.
</p>
<form
onSubmit={e => { e.preventDefault(); scan() }}
className="flex gap-2 max-w-lg mx-auto"
>
<input
type="text"
value={url}
onChange={e => 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"
/>
<button
type="submit"
disabled={!url.trim()}
className="bg-primary text-primary-foreground px-5 py-3 rounded-lg text-sm font-sans font-medium hover:bg-primary/90 disabled:opacity-40 disabled:cursor-not-allowed transition-all flex items-center gap-2 cursor-pointer"
>
Scan
<ArrowRight className="h-4 w-4" />
</button>
</form>
<p className="text-xs text-muted-foreground/50 mt-4 font-sans">
Public repos only &middot; No code is stored &middot; Powered by <a href="https://github.com/GreyhavenHQ/greywall" target="_blank" rel="noopener noreferrer" className="text-primary hover:text-primary/80 transition-colors">Greywall</a>
</p>
</div>
</section>
)}
{/* ── SCANNING PHASE ── */}
{phase === 'scanning' && (
<section className="min-h-[calc(100vh-3.5rem)] flex items-center justify-center px-4 sm:px-6 py-12">
<div className="max-w-2xl w-full animate-fade-up">
<div className="flex items-center gap-2 mb-4">
<Shield className="h-4 w-4 text-primary" />
<span className="text-xs font-sans uppercase tracking-wider text-primary font-medium">
Scanning {repoName}
</span>
</div>
<div
ref={termRef}
className="code-block p-5 sm:p-6 max-h-[60vh] overflow-y-auto scrollbar-hide"
>
<div className="flex items-center gap-2 mb-5">
<div className="w-3 h-3 rounded-full bg-red-500/70" />
<div className="w-3 h-3 rounded-full bg-yellow-500/70" />
<div className="w-3 h-3 rounded-full bg-green-500/70" />
<span className="ml-2 text-xs font-mono text-muted-foreground">
greywall scan
</span>
</div>
<div className="space-y-1.5 font-mono text-xs sm:text-sm">
{lines.map(line => (
<div key={line.id} className="animate-fade-up" style={{ animationDuration: '0.3s' }}>
{line.type === 'info' && (
<span className="text-muted-foreground">
<span className="opacity-50">$ </span>{line.text}
</span>
)}
{line.type === 'success' && (
<span className="text-green-400">
<span className="opacity-50">{'\u2713'} </span>{line.text}
</span>
)}
{line.type === 'warning' && (
<span className="text-primary">
<span className="opacity-60">{'\u26A0'} </span>{line.text}
</span>
)}
{line.type === 'error' && (
<span className="text-red-400">
<span className="opacity-60">{'\u2717'} </span>{line.text}
</span>
)}
</div>
))}
{!error && (
<div className="inline-block w-1.5 h-4 bg-primary/70 animate-pulse mt-1" />
)}
</div>
</div>
{error && (
<div className="mt-4 text-center">
<button
onClick={reset}
className="text-sm text-primary hover:text-primary/80 transition-colors font-sans cursor-pointer"
>
&larr; Try another repo
</button>
</div>
)}
</div>
</section>
)}
{/* ── REPORT PHASE ── */}
{phase === 'report' && report && (
<section className="px-4 sm:px-6 py-12 sm:py-16">
<div className="max-w-2xl mx-auto">
{/* Report Card */}
<div className="border border-border/30 rounded-xl overflow-hidden bg-card/20 animate-fade-up glow-orange">
{/* Header */}
<div className="p-6 sm:p-8 border-b border-border/20">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2 mb-3">
<Shield className="h-4 w-4 text-primary" />
<span className="text-xs font-sans uppercase tracking-wider text-primary font-medium">
What an agent would try on your machine
</span>
</div>
<h2 className="font-mono text-lg sm:text-xl text-foreground mb-1">
{repoName}
</h2>
<p className="text-xs text-muted-foreground font-sans">
{detectedStack.join(' \u00b7 ')}
</p>
</div>
{/* Risk Score Circle */}
<div className="relative w-24 h-24 sm:w-28 sm:h-28 flex-shrink-0">
<svg className="w-full h-full -rotate-90" viewBox="0 0 100 100">
<circle
cx="50" cy="50" r="45" fill="none"
stroke="rgb(var(--border))" strokeWidth="5" opacity="0.3"
/>
<circle
cx="50" cy="50" r="45" fill="none"
stroke={color} strokeWidth="5"
strokeDasharray={circumference}
strokeDashoffset={circumference - (animatedScore / 100) * circumference}
strokeLinecap="round"
className="transition-[stroke-dashoffset] duration-100"
/>
</svg>
<div className="absolute inset-0 flex flex-col items-center justify-center">
<span className="text-2xl sm:text-3xl font-bold font-sans" style={{ color }}>
{animatedScore}
</span>
<span className="text-[10px] text-muted-foreground uppercase tracking-wider">
/100
</span>
</div>
</div>
</div>
{/* Risk Level Badge */}
<div
className="mt-4 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-sans font-medium"
style={{
background: `${color}15`,
border: `1px solid ${color}30`,
color,
}}
>
<AlertTriangle className="h-3 w-3" />
{report.riskLevel} RISK
</div>
</div>
{/* Summary */}
<div className="px-6 sm:px-8 py-4 border-b border-border/20 bg-card/10">
<p className="text-sm text-muted-foreground font-serif leading-relaxed">
{report.summary}
</p>
</div>
{/* Findings */}
<div className="divide-y divide-border/10">
{report.findings.map((finding, i) => (
<div
key={i}
className="px-6 sm:px-8 py-4 animate-fade-up"
style={{ animationDelay: `${i * 100}ms` }}
>
<div className="flex items-start gap-3">
<div className={`mt-0.5 p-1.5 rounded ${severityBg(finding.severity)} ${severityColor(finding.severity)}`}>
{typeIcon(finding.type)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`text-[10px] font-sans font-medium uppercase tracking-wider ${severityColor(finding.severity)}`}>
{finding.severity}
</span>
</div>
<h3 className="text-sm font-sans font-medium text-foreground mb-1">
{finding.title}
</h3>
<p className="text-xs text-muted-foreground font-serif leading-relaxed mb-2">
{finding.description}
</p>
<code className="text-[11px] font-mono text-muted-foreground/70 bg-background/50 px-2 py-1 rounded break-all inline-block">
{finding.command}
</code>
</div>
</div>
</div>
))}
</div>
{/* CTA */}
<div className="px-6 sm:px-8 py-6 sm:py-8 border-t border-border/20 bg-card/20 text-center">
<p className="text-xs text-muted-foreground/50 font-sans mb-4">
This is not a security certification. It&apos;s a demonstration of what&apos;s possible without a sandbox.
</p>
<p className="font-serif text-lg sm:text-xl font-semibold tracking-tight mb-2">
Greywall blocks this by default.
</p>
<p className="text-xs text-muted-foreground font-serif mb-5">
Container-free sandboxing with real-time observability for AI agents.
</p>
<a
href="https://github.com/GreyhavenHQ/greywall"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 bg-primary text-primary-foreground px-5 py-2.5 rounded-lg text-sm font-sans font-medium hover:bg-primary/90 transition-all"
>
Install Greywall
<ArrowRight className="h-4 w-4" />
</a>
</div>
</div>
{/* Actions */}
<div className="flex items-center justify-center gap-3 mt-6">
<button
onClick={copyLink}
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-border/30 text-sm text-muted-foreground hover:text-foreground hover:border-border/50 transition-all font-sans cursor-pointer"
>
{copied ? <Check className="h-4 w-4 text-green-400" /> : <Copy className="h-4 w-4" />}
{copied ? 'Copied!' : 'Share Report'}
</button>
<button
onClick={reset}
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-border/30 text-sm text-muted-foreground hover:text-foreground hover:border-border/50 transition-all font-sans cursor-pointer"
>
Scan Another Repo
</button>
</div>
</div>
</section>
)}
</div>
</main>
)
}