feat: repo scanner
This commit is contained in:
69
app/api/analyze/prompt.txt
Normal file
69
app/api/analyze/prompt.txt
Normal 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
77
app/api/analyze/route.ts
Normal 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
10
app/greyscan/layout.tsx
Normal 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
614
app/greyscan/page.tsx
Normal 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'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 · No code is stored · 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"
|
||||
>
|
||||
← 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">“</span>
|
||||
This is what Greywall would have blocked.
|
||||
<span className="text-primary">”</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user