feat: repo scanner

This commit is contained in:
Nik L
2026-03-16 17:02:44 -04:00
parent 1c89ab47fc
commit adea1fec5b
6 changed files with 777 additions and 0 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@ out/
*.tsbuildinfo
next-env.d.ts
.vercel
.env

View File

@@ -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": <number 0-100>,
"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": "<short, specific, alarming title>",
"description": "<1-2 sentences: what the agent would do, referencing actual files/deps from this repo, and the real-world damage>",
"command": "<the exact command or action, e.g. 'cat .env.production' or 'curl -H \"Authorization: Bearer $STRIPE_KEY\" https://api.stripe.com/v1/charges'>"
}
]
}
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

77
app/api/analyze/route.ts Normal file
View File

@@ -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<string, { data: any; timestamp: number }>()
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 })
}
}

10
app/greyscan/layout.tsx Normal file
View File

@@ -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
}

614
app/greyscan/page.tsx Normal file
View File

@@ -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<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" />
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)
// 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 }),
})
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</em> on your repo?
</h1>
<p className="text-muted-foreground font-serif text-base sm:text-lg leading-relaxed mb-10 max-w-xl mx-auto">
Paste a public GitHub URL. We&apos;ll scan your codebase and show exactly what an unrestricted AI agent would attempt.
</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">
Agent Threat Report
</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="font-serif text-lg sm:text-xl font-semibold tracking-tight mb-2">
<span className="text-primary">&ldquo;</span>
This is what Greywall would have blocked.
<span className="text-primary">&rdquo;</span>
</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>
)
}

View File

@@ -36,6 +36,12 @@ export function Nav() {
>
About
</a>
<a
href="/greyscan"
className="text-sm text-primary hover:text-primary/80 transition-colors hidden sm:block font-medium"
>
Greyscan
</a>
<a
href="https://github.com/GreyhavenHQ/greywall"
target="_blank"