- 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