diff --git a/README.md b/README.md index cfe2e8e..b93628d 100755 --- a/README.md +++ b/README.md @@ -394,4 +394,45 @@ journalctl -u redcalibur -n 100 --no-pager - Frontend can’t reach API in dev: ensure the backend is running and Vite proxy is active - Port conflicts: Vite will choose another port (5174) if 5173 is busy; change API port with `--port` in `api/run.py` if needed - Scripts say venv missing: create the venv at `.venv` and install requirements (see Quickstart) -- Missing keys: the app will still run, but some features return reduced data \ No newline at end of file +- Missing keys: the app will still run, but some features return reduced data + +--- + +## ☁️ Deploy to Vercel (Frontend only) + +Vercel hosts the React frontend. The FastAPI backend should run on a persistent host (Render/Fly.io/Railway/EC2/your server). You have two ways to connect the frontend to the backend: + +Option A — Environment variable (simplest) +1) Deploy your FastAPI backend to a public HTTPS URL (confirm `/health`). +2) In Vercel → New Project → Import this repo. Set Project root directory to `frontend`. +3) Build settings: + - Build Command: `npm run build` + - Output Directory: `dist` +4) Add an environment variable in Vercel → Settings → Environment Variables: + - Name: `VITE_API_BASE` + - Value: `https://your-backend.example.com` + - Scope: Production (and Preview if desired) +5) Deploy. The app will call your backend directly using that URL. + +Option B — Rewrites (no CORS, keep `/api`) +1) Deploy your FastAPI backend to a public HTTPS URL. +2) Add a `vercel.json` in the `frontend/` folder with: +```json +{ + "rewrites": [ + { "source": "/api/(.*)", "destination": "https://your-backend.example.com/$1" } + ] +} +``` +3) Do not set `VITE_API_BASE` in Vercel. The app uses `/api`, which Vercel proxies to your backend. +4) In Vercel, set root to `frontend`, build to `npm run build`, output to `dist`, then deploy. + +Checks after deploy +- Open your Vercel site → header health badge should show `ok`. +- DevTools → Network: `/api/health` should succeed (rewrites) or calls should hit your backend URL (env var). + +Why FastAPI is required +- The browser cannot perform many OSINT tasks (port scans, WHOIS, raw sockets, multi-host probes) due to CORS, sandboxing, and blocked network primitives. +- API keys (Shodan, VirusTotal, Gemini) must be kept server-side. Exposing them in the frontend would leak your secrets. +- The backend orchestrates parallel tasks with timeouts/retries and aggregates results, which is not reliable purely client-side. +- Some tasks are long-running or network-heavy and need server resources and control. \ No newline at end of file diff --git a/api/app.py b/api/app.py index 9e26a6d..47a4a1b 100644 --- a/api/app.py +++ b/api/app.py @@ -14,7 +14,7 @@ import time from redcalibur.osint.network_threat_intel.shodan_integration import perform_shodan_scan from redcalibur.osint.user_identity.username_lookup import lookup_username -from redcalibur.osint.virustotal_integration import scan_url +from redcalibur.osint.virustotal_integration import scan_url_full from redcalibur.osint.url_health_check import basic_url_health from redcalibur.osint.ai_enhanced.recon_summarizer import summarize_recon_data from redcalibur.osint.ai_enhanced.risk_scoring import calculate_risk_score @@ -192,7 +192,7 @@ def urlscan(req: URLScanRequest): def runner(): if not config.VIRUSTOTAL_API_KEY: return {"note": "VIRUSTOTAL_API_KEY not configured", "health": basic_url_health(req.url)} - return scan_url(config.VIRUSTOTAL_API_KEY, req.url) or {"error": "no_data"} + return scan_url_full(config.VIRUSTOTAL_API_KEY, req.url) or {"error": "no_data"} with ThreadPoolExecutor(max_workers=1) as ex_url: fut = ex_url.submit(runner) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c779b2d..97f7ad0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", + "react-router-dom": "^6.30.1", "remark-gfm": "^4.0.0" }, "devDependencies": { @@ -826,6 +827,15 @@ "node": ">=14" } }, + "node_modules/@remix-run/router": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", + "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -3659,6 +3669,38 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", + "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", + "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0", + "react-router": "6.30.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 02ed15c..cead249 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,12 +12,13 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", + "react-router-dom": "^6.30.1", "remark-gfm": "^4.0.0" }, "devDependencies": { - "@vitejs/plugin-react": "^4.3.2", "@types/react": "^18.2.48", "@types/react-dom": "^18.2.17", + "@vitejs/plugin-react": "^4.3.2", "autoprefixer": "^10.4.20", "postcss": "^8.4.47", "tailwindcss": "^3.4.13", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ba519d5..c11a45d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -61,7 +61,11 @@ const Header = ({ palette, setPalette, fx, setFx, apiStatus }) => { } export default function App() { - const [loading, setLoading] = useState(false) + // Independent loading flags per section so only the triggered action shows a spinner + const [domainLoading, setDomainLoading] = useState(false) + const [scanLoading, setScanLoading] = useState(false) + const [usernameLoading, setUsernameLoading] = useState(false) + const [urlLoading, setUrlLoading] = useState(false) const [result, setResult] = useState(null) const [logs, setLogs] = useState([]) const [apiStatus, setApiStatus] = useState('unknown') @@ -84,9 +88,13 @@ export default function App() { if (palette==='blue') { root.style.setProperty('--primary-rgb','59,130,246') root.style.setProperty('--secondary-rgb','244,63,94') + root.style.setProperty('--primary-color','#4299E1') + root.style.setProperty('--glow-color','rgba(66,153,225,0.75)') } else { root.style.setProperty('--primary-rgb','244,63,94') root.style.setProperty('--secondary-rgb','59,130,246') + root.style.setProperty('--primary-color','#E53E3E') + root.style.setProperty('--glow-color','rgba(229,62,62,0.75)') } try { localStorage.setItem('rc_palette', palette) } catch {} }, [palette]) @@ -95,7 +103,6 @@ export default function App() { const api = useMemo(()=>({ post: async (path, body, {updateResult=true}={}) => { - setLoading(true) const start = performance.now() try { const res = await fetch(`${API_BASE}${path}`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) }) @@ -106,7 +113,7 @@ export default function App() { } catch(e) { setLogs(l=>[{ time:new Date().toLocaleTimeString(), path, ok:false, error:e.message }, ...l]) throw e - } finally { setLoading(false) } + } } }), []) @@ -122,10 +129,30 @@ export default function App() {
-
api.post('/domain', data)} loading={loading} />
-
api.post('/scan', data)} loading={loading} />
-
api.post('/username', data)} loading={loading} />
-
api.post('/urlscan', data)} loading={loading} />
+
+ { setDomainLoading(true); try { await api.post('/domain', data) } finally { setDomainLoading(false) } }} + loading={domainLoading} + /> +
+
+ { setScanLoading(true); try { await api.post('/scan', data) } finally { setScanLoading(false) } }} + loading={scanLoading} + /> +
+
+ { setUsernameLoading(true); try { await api.post('/username', data) } finally { setUsernameLoading(false) } }} + loading={usernameLoading} + /> +
+
+ { setUrlLoading(true); try { await api.post('/urlscan', data) } finally { setUrlLoading(false) } }} + loading={urlLoading} + /> +
diff --git a/frontend/src/components/Landing.jsx b/frontend/src/components/Landing.jsx new file mode 100644 index 0000000..84cc947 --- /dev/null +++ b/frontend/src/components/Landing.jsx @@ -0,0 +1,106 @@ +import React, { useEffect, useRef, useState } from 'react' + +export default function Landing({ palette, setPalette, onEnter, generateMission }) { + const [toggleState, setToggleState] = useState(0) + const [isGenerating, setIsGenerating] = useState(false) + const missionRef = useRef(null) + + // Inject Share Tech Mono font for landing + useEffect(() => { + const link1 = document.createElement('link') + link1.rel = 'preconnect'; link1.href = 'https://fonts.googleapis.com' + const link2 = document.createElement('link') + link2.rel = 'preconnect'; link2.href = 'https://fonts.gstatic.com'; link2.crossOrigin = 'anonymous' + const link3 = document.createElement('link') + link3.href = 'https://fonts.googleapis.com/css2?family=Share+Tech+Mono&display=swap'; link3.rel = 'stylesheet' + document.head.append(link1, link2, link3) + return () => { link1.remove(); link2.remove(); link3.remove(); } + }, []) + + // Typewriter effect + const typeWriter = (el, text, speed = 30) => { + if (!el) return + let i = 0 + el.innerHTML = '' + const cursor = document.createElement('span') + cursor.style.borderRight = '3px solid var(--primary-color)' + cursor.style.animation = 'blink-caret .75s step-end infinite' + el.appendChild(cursor) + const typing = () => { + if (i < text.length) { + cursor.insertAdjacentText('beforebegin', text.charAt(i)) + i++ + window.setTimeout(typing, speed) + } else { + cursor.style.animation = 'none'; cursor.style.borderRight = 'none' + } + } + typing() + } + + const themes = [ + { name: 'red', primary: '#E53E3E', glow: 'rgba(229, 62, 62, 0.75)' }, + { name: 'blue', primary: '#4299E1', glow: 'rgba(66, 153, 225, 0.75)' }, + ] + + const cycleTheme = () => { + const next = (toggleState + 1) % themes.length + setToggleState(next) + const t = themes[next] + document.documentElement.style.setProperty('--primary-color', t.primary) + document.documentElement.style.setProperty('--glow-color', t.glow) + setPalette(t.name) + } + + const handleGenerate = async () => { + if (isGenerating) return + setIsGenerating(true) + if (missionRef.current) missionRef.current.innerHTML = '
' + try { + const mission = await generateMission() + typeWriter(missionRef.current, (mission || 'Transmission failed.').trim()) + } catch (e) { + if (missionRef.current) missionRef.current.textContent = 'Error: Could not connect to Archangel network. Please try again.' + } finally { setIsGenerating(false) } + } + + return ( +
+ + + {/* Theme toggle in top-right */} +
+ Theme: + +
+ + {/* Card */} +
+

Welcome to Red Calibur

+ + +
+
+

Awaiting transmission...

+
+ +
+
+
+ ) +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx index 83d0bca..3bc19ed 100644 --- a/frontend/src/main.jsx +++ b/frontend/src/main.jsx @@ -1,10 +1,18 @@ import React from 'react' import { createRoot } from 'react-dom/client' +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom' import App from './App' +import LandingPage from './pages/LandingPage' import './styles.css' createRoot(document.getElementById('root')).render( - + + + } /> + } /> + } /> + + ) diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx new file mode 100644 index 0000000..6f98e60 --- /dev/null +++ b/frontend/src/pages/LandingPage.jsx @@ -0,0 +1,43 @@ +import React, { useMemo, useState, useEffect } from 'react' +import { useNavigate } from 'react-router-dom' +import Landing from '../components/Landing' + +const API_BASE = import.meta.env.VITE_API_BASE || '/api' + +export default function LandingPage(){ + const navigate = useNavigate() + const [palette, setPalette] = useState(() => (typeof window==='undefined' ? 'red' : (localStorage.getItem('rc_palette')||'red'))) + + useEffect(() => { + const root = document.documentElement + if (palette==='blue') { + root.style.setProperty('--primary-rgb','59,130,246') + root.style.setProperty('--secondary-rgb','244,63,94') + root.style.setProperty('--primary-color','#4299E1') + root.style.setProperty('--glow-color','rgba(66,153,225,0.75)') + } else { + root.style.setProperty('--primary-rgb','244,63,94') + root.style.setProperty('--secondary-rgb','59,130,246') + root.style.setProperty('--primary-color','#E53E3E') + root.style.setProperty('--glow-color','rgba(229,62,62,0.75)') + } + try { localStorage.setItem('rc_palette', palette) } catch {} + }, [palette]) + + const generateMission = useMemo(()=> async () => { + const payload = { payload: { brief: 'Generate a short cryptic hacker mission briefing (<40 words). Code name: Archangel. Target: corporate project. Style: tense, high-stakes.' } } + try { + const res = await fetch(`${API_BASE}/summarize`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload) }) + const json = await res.json(); return json?.summary || 'No mission received.' + } catch { return 'Error contacting mission service.' } + }, []) + + return ( + navigate('/app')} + /> + ) +} diff --git a/frontend/vercel.example.json b/frontend/vercel.example.json new file mode 100644 index 0000000..d352317 --- /dev/null +++ b/frontend/vercel.example.json @@ -0,0 +1,5 @@ +{ + "rewrites": [ + { "source": "/api/(.*)", "destination": "https://your-backend.example.com/$1" } + ] +} diff --git a/redcalibur/osint/virustotal_integration.py b/redcalibur/osint/virustotal_integration.py index 2c5d200..90540c2 100755 --- a/redcalibur/osint/virustotal_integration.py +++ b/redcalibur/osint/virustotal_integration.py @@ -1,23 +1,18 @@ +import base64 +import time import requests -DEFAULT_TIMEOUT = 8.0 +DEFAULT_TIMEOUT = 8.0 # per-request timeout def scan_url(api_key: str, url: str): """ - Scan a URL using the VirusTotal API. + Submit a URL for scanning using the VirusTotal API. - :param api_key: Your VirusTotal API key - :param url: The URL to scan - :return: Scan results + Returns the raw submission response (often contains an analysis id). """ vt_url = "https://www.virustotal.com/api/v3/urls" - headers = { - "x-apikey": api_key - } - data = { - "url": url - } - + headers = {"x-apikey": api_key} + data = {"url": url} try: response = requests.post(vt_url, headers=headers, data=data, timeout=DEFAULT_TIMEOUT) if response.status_code == 200: @@ -28,17 +23,22 @@ def scan_url(api_key: str, url: str): def get_url_report(api_key: str, url_id: str): """ - Get the scan report for a URL using the VirusTotal API. - - :param api_key: Your VirusTotal API key - :param url_id: The ID of the scanned URL - :return: Report results + Get the latest report for a URL using the VirusTotal API (by URL ID). """ vt_url = f"https://www.virustotal.com/api/v3/urls/{url_id}" - headers = { - "x-apikey": api_key - } + headers = {"x-apikey": api_key} + try: + response = requests.get(vt_url, headers=headers, timeout=DEFAULT_TIMEOUT) + if response.status_code == 200: + return response.json() + return {"error": "virustotal_error", "status": response.status_code, "body": response.text} + except Exception as e: + return {"error": str(e)} +def get_analysis(api_key: str, analysis_id: str): + """Fetch a specific analysis object by id.""" + vt_url = f"https://www.virustotal.com/api/v3/analyses/{analysis_id}" + headers = {"x-apikey": api_key} try: response = requests.get(vt_url, headers=headers, timeout=DEFAULT_TIMEOUT) if response.status_code == 200: @@ -46,3 +46,86 @@ def get_url_report(api_key: str, url_id: str): return {"error": "virustotal_error", "status": response.status_code, "body": response.text} except Exception as e: return {"error": str(e)} + +def url_to_vt_id(url: str) -> str: + """Encode a URL to VirusTotal URL ID (urlsafe base64 without padding).""" + enc = base64.urlsafe_b64encode(url.encode()).decode() + return enc.rstrip('=') + +def scan_url_full(api_key: str, url: str, overall_timeout: float = 9.0, poll_interval: float = 1.0): + """ + End-to-end URL scan that submits, polls for completion briefly, and returns structured results. + + Returns: + { + "source": "virustotal", + "submitted": { ...raw submission... }, + "analysis": { ...latest analysis object if fetched... }, + "report": { + "last_analysis_stats": { harmless, malicious, suspicious, undetected, timeout }, + "reputation": int | None, + "total_vendors": int, + "malicious_vendors": [ { "engine": name, "category": cat } ... up to 5 ], + "link": "https://www.virustotal.com/gui/url/" + } + } + or a pending/timeout structure if not ready in time. + """ + start = time.time() + result: dict = {"source": "virustotal"} + + submission = scan_url(api_key, url) + result["submitted"] = submission + + # Try to get analysis id from submission + analysis_id = None + try: + analysis_id = submission.get("data", {}).get("id") + except Exception: + pass + + # Compute URL ID for report link and final stats + url_id = url_to_vt_id(url) + result_link = f"https://www.virustotal.com/gui/url/{url_id}" + + # Poll the analysis endpoint briefly + analysis_obj = None + while analysis_id and (time.time() - start) < overall_timeout: + a = get_analysis(api_key, analysis_id) + if isinstance(a, dict) and a.get("data", {}).get("attributes", {}).get("status") == "completed": + analysis_obj = a + break + time.sleep(poll_interval) + + result["analysis"] = analysis_obj or {"note": "analysis_pending"} + + # Fetch latest URL report to get last_analysis_stats + report = get_url_report(api_key, url_id) + rep_attrs = report.get("data", {}).get("attributes", {}) if isinstance(report, dict) else {} + stats = rep_attrs.get("last_analysis_stats", {}) + vendors = rep_attrs.get("last_analysis_results", {}) + # extract a small list of malicious vendors (up to 5) + mal_vendors = [] + if isinstance(vendors, dict): + for eng, v in vendors.items(): + try: + if (v or {}).get("category") == "malicious": + mal_vendors.append({"engine": eng, "category": v.get("category")}) + except Exception: + continue + mal_vendors = mal_vendors[:5] + + summary = { + "last_analysis_stats": stats or None, + "reputation": rep_attrs.get("reputation"), + "total_vendors": len(vendors) if isinstance(vendors, dict) else None, + "malicious_vendors": mal_vendors, + "link": result_link, + } + result["report"] = summary + + # If still nothing meaningful, indicate pending + if not stats: + result["note"] = "analysis_pending_or_insufficient_time" + + return result