diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c9f4b31 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +VITE_API_URL=http://localhost:8000 +VITE_SUPABASE_URL=https://.supabase.co +VITE_SUPABASE_ANON_KEY= diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml new file mode 100644 index 0000000..cc43fa4 --- /dev/null +++ b/.github/workflows/frontend.yml @@ -0,0 +1,23 @@ +name: frontend + +on: + push: + paths: + - 'src/**' + - 'package.json' + - 'vite.config.ts' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm ci + - run: npm run build + - run: | + if [ -f node_modules/.bin/eslint ]; then + npm run lint + fi diff --git a/README.md b/README.md index 6182eb5..632aafc 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,22 @@ catchattack/ npm run build ``` +### Running Front-end + +Create a `.env` file at project root with: + +```bash +VITE_API_URL=http://localhost:8000 +VITE_SUPABASE_URL=https://.supabase.co +VITE_SUPABASE_ANON_KEY= +``` + +Then start the dev server: + +```bash +npm run dev +``` + ### Start Backend API The Python backend is located in `backend/`. Install dependencies and set up the environment file: diff --git a/backend/.env.example b/backend/.env.example index fd5bec5..d298cc1 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,2 +1,5 @@ SUPABASE_URL=http://localhost:54321 SUPABASE_ANON_KEY= +CALDERA_URL= +CALDERA_USER= +CALDERA_PASS= diff --git a/backend/README.md b/backend/README.md index 5375d45..e6d7137 100644 --- a/backend/README.md +++ b/backend/README.md @@ -18,6 +18,9 @@ The emulator service requires the following variables: ``` SUPABASE_URL=http://localhost:54321 SUPABASE_ANON_KEY= +CALDERA_URL= +CALDERA_USER= +CALDERA_PASS= ``` Add these variables to a `.env` file. An example template is provided in @@ -29,6 +32,10 @@ uvicorn backend.main:app --reload ``` Visit `http://localhost:8000/docs` for interactive API documentation. +### New Endpoint + +`GET /techniques/{id}/full` returns MITRE data combined with Atomic Red Team tests and CALDERA abilities. + ## Directory Overview ``` backend/ diff --git a/backend/main.py b/backend/main.py index 8af4d06..983b614 100644 --- a/backend/main.py +++ b/backend/main.py @@ -8,6 +8,7 @@ from . import schemas from pathlib import Path as FilePath from .services import emulator, mitre, sigma, yaml_generator, vm_manager +from .services import security_sources from datetime import datetime app = FastAPI(title="CatchAttack Backend") @@ -63,12 +64,29 @@ def list_techniques(): return {"techniques": techniques} +@app.get("/techniques/{tech_id}/full") +def full_technique(tech_id: str = Path(..., min_length=1)): + data = security_sources.get_full_technique(tech_id) + if not data: + raise HTTPException(status_code=404, detail="Technique not found") + return data + + @app.post("/sigma/{technique_id}") def create_sigma(technique_id: str = Path(..., min_length=1)): + info = security_sources.get_full_technique(technique_id) + if not info: + raise HTTPException(status_code=404, detail="Technique not found") try: - return sigma.generate_sigma_rule(technique_id) + rule = sigma.generate_sigma_rule(technique_id) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + rule.update({ + "technique": info["technique"], + "atomic_tests": info["atomic_tests"], + "caldera_abilities": info["caldera_abilities"], + }) + return rule @app.post("/yaml/{technique_id}") @@ -76,14 +94,10 @@ def generate_yaml( config: schemas.VMConfig, technique_id: str = Path(..., min_length=1), ): - try: - data = mitre.fetch_techniques() - except Exception as e: - raise HTTPException(status_code=502, detail=f"MITRE fetch failed: {e}") - - technique = next((t for t in data if t.get("id") == technique_id), None) - if not technique: + info = security_sources.get_full_technique(technique_id) + if not info: raise HTTPException(status_code=404, detail="Technique not found") + technique = info["technique"] merged = { "technique": { diff --git a/backend/requirements.txt b/backend/requirements.txt index 4fb1c95..6433d5c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -6,3 +6,4 @@ stix2 taxii2-client PyYAML requests +requests-mock diff --git a/backend/services/art.py b/backend/services/art.py new file mode 100644 index 0000000..0dd16f4 --- /dev/null +++ b/backend/services/art.py @@ -0,0 +1,49 @@ +import json +import logging +import time +from pathlib import Path +from typing import List, Dict + +import requests +import yaml + +BASE_URL = "https://raw.githubusercontent.com/redcanaryco/atomic-red-team/master/atomics" +CACHE_DIR = Path("generated/atomic_tests") +CACHE_DIR.mkdir(parents=True, exist_ok=True) + +_CACHE: Dict[str, List[dict]] = {} +_LAST_FETCH: Dict[str, float] = {} +_TTL = 60 * 60 * 24 # 24h + + +def get_atomic_tests(tech_id: str) -> List[dict]: + """Return Atomic Red Team tests for a technique. + + Tests are cached on disk for 24 hours. + """ + now = time.time() + if tech_id in _CACHE and now - _LAST_FETCH.get(tech_id, 0) < _TTL: + return _CACHE[tech_id] + + url = f"{BASE_URL}/{tech_id}/{tech_id}.yaml" + cache_file = CACHE_DIR / f"{tech_id}.json" + try: + resp = requests.get(url, timeout=10) + resp.raise_for_status() + data = yaml.safe_load(resp.text) or {} + tests = data.get("atomic_tests", []) + _CACHE[tech_id] = tests + _LAST_FETCH[tech_id] = now + cache_file.write_text(json.dumps(tests)) + return tests + except Exception as exc: # noqa: BLE001 + logging.exception("Atomic fetch failed: %s", exc) + if cache_file.exists(): + try: + tests = json.loads(cache_file.read_text()) + _CACHE[tech_id] = tests + _LAST_FETCH[tech_id] = now + return tests + except Exception: # noqa: BLE001 + logging.exception("Failed to load cached atomic tests") + return [] diff --git a/backend/services/caldera.py b/backend/services/caldera.py new file mode 100644 index 0000000..7f69267 --- /dev/null +++ b/backend/services/caldera.py @@ -0,0 +1,32 @@ +import os +import requests +import logging +from typing import List, Dict, Any + +TIMEOUT = 10 + + +def _enabled() -> bool: + return all( + [os.getenv("CALDERA_URL"), os.getenv("CALDERA_USER"), os.getenv("CALDERA_PASS")] + ) + + +def _request(path: str) -> Any: + base = os.getenv("CALDERA_URL", "") + user = os.getenv("CALDERA_USER", "") + pwd = os.getenv("CALDERA_PASS", "") + resp = requests.get(f"{base}{path}", auth=(user, pwd), timeout=TIMEOUT) + resp.raise_for_status() + return resp.json() + + +def get_abilities(tech_id: str) -> Any: + if not _enabled(): + return {"status": "disabled"} + try: + data = _request(f"/api/v2/abilities?technique_id={tech_id}") + return data + except Exception as exc: # noqa: BLE001 + logging.exception("CALDERA abilities error: %s", exc) + return [] diff --git a/backend/services/security_sources.py b/backend/services/security_sources.py new file mode 100644 index 0000000..053ad56 --- /dev/null +++ b/backend/services/security_sources.py @@ -0,0 +1,30 @@ +from typing import Optional, Dict, Any + +from . import mitre +from . import art +from . import caldera + + +def get_techniques() -> list[dict]: + return mitre.fetch_techniques() + + +def get_full_technique(tech_id: str) -> Optional[Dict[str, Any]]: + techs = mitre.fetch_techniques() + technique = next((t for t in techs if t.get("id") == tech_id), None) + if not technique: + return None + + atomic_tests = art.get_atomic_tests(tech_id) + caldera_abilities = caldera.get_abilities(tech_id) + return { + "technique": { + "id": tech_id, + "name": technique.get("name"), + "description": technique.get("description"), + "platforms": technique.get("x_mitre_platforms", []), + "data_sources": technique.get("x_mitre_data_sources", []), + }, + "atomic_tests": atomic_tests, + "caldera_abilities": caldera_abilities, + } diff --git a/src/components/detection/CoverageTabContent.tsx b/src/components/detection/CoverageTabContent.tsx index db06428..2e2e6ef 100644 --- a/src/components/detection/CoverageTabContent.tsx +++ b/src/components/detection/CoverageTabContent.tsx @@ -1,6 +1,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import MitreMatrix from "@/components/mitre/MitreMatrix"; +import { useQuery } from '@tanstack/react-query'; +import { getTechniques } from '@/services/api'; import CoverageAnalysis from "@/components/detection/CoverageAnalysis"; import DetectionRules from "@/components/detection/DetectionRules"; import { EmulationResult } from "@/types/backend"; @@ -30,6 +32,7 @@ const CoverageTabContent = ({ selectedTechniques, onTechniqueSelect }: CoverageTabContentProps) => { + const { data } = useQuery(['techniques'], () => getTechniques().then(r => r.data)); return (
@@ -46,11 +49,12 @@ const CoverageTabContent = ({ Visualization of covered techniques in the MITRE ATT&CK framework - diff --git a/src/components/mitre/MitreMatrix.tsx b/src/components/mitre/MitreMatrix.tsx index 3d34b8f..29c96d7 100644 --- a/src/components/mitre/MitreMatrix.tsx +++ b/src/components/mitre/MitreMatrix.tsx @@ -1,7 +1,12 @@ import React, { useState, useEffect } from "react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { aiService } from "@/services/aiService"; +import { getTechniques, startVm, generateYaml, startEmulation } from "@/services/api"; +import { useTechnique } from "@/hooks/useTechnique"; +import { Button } from "@/components/ui/button"; +import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { toast } from "@/components/ui/use-toast"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -14,30 +19,40 @@ interface MitreMatrixProps { onTechniqueSelect?: (techniqueId: string) => void; coveredTechniques?: string[]; isInteractive?: boolean; + techniques?: MitreAttackTechnique[]; } const MitreMatrix: React.FC = ({ selectedTechniques = [], onTechniqueSelect, coveredTechniques = [], - isInteractive = true + isInteractive = true, + techniques: externalTechniques }) => { const [techniques, setTechniques] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [tactics, setTactics] = useState([]); + const [activeTech, setActiveTech] = useState(null); + const { data: fullData } = useTechnique(activeTech ?? ''); useEffect(() => { + if (externalTechniques && externalTechniques.length > 0) { + setTechniques(externalTechniques); + const uniqueTactics = [...new Set(externalTechniques.map(t => t.tactic))]; + setTactics(uniqueTactics.sort()); + setLoading(false); + return; + } + const fetchMitreTechniques = async () => { try { setLoading(true); - const response = await aiService.getMitreTechniques(); - - if (response && response.techniques) { - setTechniques(response.techniques); - - // Extract unique tactics - const uniqueTactics = [...new Set(response.techniques.map(t => t.tactic))]; + const response = await getTechniques(); + const data = response.data; + if (data && data.techniques) { + setTechniques(data.techniques); + const uniqueTactics = [...new Set(data.techniques.map(t => t.tactic))]; setTactics(uniqueTactics.sort()); } else { setError("Invalid response format from MITRE ATT&CK service"); @@ -54,8 +69,9 @@ const MitreMatrix: React.FC = ({ }, []); const handleTechniqueClick = (techniqueId: string) => { - if (isInteractive && onTechniqueSelect) { - onTechniqueSelect(techniqueId); + if (isInteractive) { + setActiveTech(techniqueId); + onTechniqueSelect?.(techniqueId); } }; @@ -103,6 +119,7 @@ const MitreMatrix: React.FC = ({ } return ( + setActiveTech(null)}> MITRE ATT&CK Matrix @@ -162,6 +179,38 @@ const MitreMatrix: React.FC = ({ + {activeTech && ( + + + {fullData?.technique.name || activeTech} + +
+

{fullData?.technique.description}

+ {fullData?.atomic_tests && ( +
+

Atomic tests

+ {fullData.atomic_tests.map((t: any, i: number) => ( +
+
+ {t.name} + +
+
+ ))} +
+ )} + {Array.isArray(fullData?.caldera_abilities) && ( +
+

CALDERA abilities

+ {fullData.caldera_abilities.map((a: any, i: number) => ( +
{a.name || a.ability_id}
+ ))} +
+ )} +
+
+ )} +
); }; diff --git a/src/hooks/useTechnique.ts b/src/hooks/useTechnique.ts new file mode 100644 index 0000000..1b184cf --- /dev/null +++ b/src/hooks/useTechnique.ts @@ -0,0 +1,5 @@ +import { useQuery } from '@tanstack/react-query'; +import { getFullTechnique } from '@/services/api'; + +export const useTechnique = (id: string) => + useQuery(['technique', id], () => getFullTechnique(id).then(r => r.data)); diff --git a/src/services/api.ts b/src/services/api.ts new file mode 100644 index 0000000..30e9028 --- /dev/null +++ b/src/services/api.ts @@ -0,0 +1,17 @@ +import axios from 'axios'; + +const API = axios.create({ baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000' }); + +export const getTechniques = () => API.get('/techniques'); +export const getFullTechnique = (id: string) => API.get(`/techniques/${id}/full`); +export const startEmulation = (id: string) => API.post('/emulate', { technique_id: id }); +export const generateYaml = (id: string, payload: any) => API.post(`/yaml/${id}`, payload); +export const startVm = (payload: any) => API.post('/vm/start', payload); + +export default { + getTechniques, + getFullTechnique, + startEmulation, + generateYaml, + startVm +}; diff --git a/tests/test_art.py b/tests/test_art.py new file mode 100644 index 0000000..2ca43bf --- /dev/null +++ b/tests/test_art.py @@ -0,0 +1,26 @@ +import sys +from pathlib import Path +import requests_mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from backend.services import art + + +def test_fetch_atomic_tests(): + yaml_data = """ +attack_technique: T1234 +atomic_tests: +- name: Example Test + executor: + name: sh + command: echo hi +""" + with requests_mock.Mocker() as m: + m.get( + "https://raw.githubusercontent.com/redcanaryco/atomic-red-team/master/atomics/T1234/T1234.yaml", + text=yaml_data, + ) + tests = art.get_atomic_tests("T1234") + assert len(tests) == 1 + assert tests[0]["name"] == "Example Test" diff --git a/tests/test_caldera.py b/tests/test_caldera.py new file mode 100644 index 0000000..7659317 --- /dev/null +++ b/tests/test_caldera.py @@ -0,0 +1,28 @@ +import sys +from pathlib import Path +import requests_mock + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from backend.services import caldera + + +def test_disabled_when_env_missing(monkeypatch): + monkeypatch.delenv("CALDERA_URL", raising=False) + monkeypatch.delenv("CALDERA_USER", raising=False) + monkeypatch.delenv("CALDERA_PASS", raising=False) + result = caldera.get_abilities("T0001") + assert result == {"status": "disabled"} + + +def test_get_abilities(monkeypatch): + monkeypatch.setenv("CALDERA_URL", "http://caldera") + monkeypatch.setenv("CALDERA_USER", "u") + monkeypatch.setenv("CALDERA_PASS", "p") + with requests_mock.Mocker() as m: + m.get( + "http://caldera/api/v2/abilities?technique_id=T0001", + json=[{"name": "ab"}], + ) + result = caldera.get_abilities("T0001") + assert result == [{"name": "ab"}] diff --git a/vite.config.ts b/vite.config.ts index 25c5589..47f2f69 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -8,6 +8,13 @@ export default defineConfig(({ mode }) => ({ server: { host: "::", port: 8080, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + rewrite: path => path.replace(/^\/api/, '') + } + } }, plugins: [ react(),