7 Commits

Author SHA1 Message Date
bb562a91ca version 0.3.1 2026-02-20 07:16:17 -05:00
04a9946891 feat: host-centric network map, analysis dashboard, deduped inventory
- Rewrote NetworkMap to use deduplicated host inventory (163 hosts from 394K rows)
- New host_inventory.py service: scans datasets, groups by FQDN/ClientId, extracts IPs/users/OS
- New /api/network/host-inventory endpoint
- Added AnalysisDashboard with 6 tabs (IOC, anomaly, host profile, query, triage, reports)
- Added 16 analysis API endpoints with job queue and load balancer
- Added 4 AI/analysis ORM models (ProcessingJob, AnalysisResult, HostProfile, IOCEntry)
- Filters system accounts (DWM-*, UMFD-*, LOCAL/NETWORK SERVICE)
- Infers OS from hostname patterns (W10-* -> Windows 10)
- Canvas 2D force-directed graph with host/external-IP node types
- Click popover shows hostname, FQDN, IPs, OS, users, datasets, connections
2026-02-20 07:16:17 -05:00
9b98ab9614 feat: interactive network map, IOC highlighting, AUP hunt selector, type filters
- NetworkMap: hunt-scoped force-directed graph with click-to-inspect popover
- NetworkMap: zoom/pan (wheel, drag, buttons), viewport transform
- NetworkMap: clickable IP/Host/Domain/URL legend chips to filter node types
- NetworkMap: brighter colors, 20% smaller nodes
- DatasetViewer: IOC columns highlighted with colored headers + cell tinting
- AUPScanner: hunt dropdown replacing dataset checkboxes, auto-select all
- Rename 'Social Media (Personal)' theme to 'Social Media' with DB migration
- Fix /api/hunts timeout: Dataset.rows lazy='noload' (was selectin cascade)
- Add OS column mapping to normalizer
- Full backend services, DB models, alembic migrations, new routes
- New components: Dashboard, HuntManager, FileUpload, NetworkMap, etc.
- Docker Compose deployment with nginx reverse proxy
2026-02-19 15:41:15 -05:00
d0c9f88268 Add ThreatHunt agent backend/frontend scaffolding 2025-12-29 10:22:57 -05:00
dc2dcd02c1 Document Analyst Assist Agents in THREATHUNT_INTENT.md
Added section on Analyst Assist Agents in ThreatHunt.
2025-12-24 13:28:52 -05:00
73a2efcde3 Add ThreatHunt roadmap with goals and non-goals
This document outlines the roadmap for ThreatHunt, detailing near, mid, and long-term goals, as well as explicit non-goals.
2025-12-24 13:08:23 -05:00
77509b08f5 docs: clarify VelociCompanion works with CSV uploads, not direct Velociraptor connection 2025-12-09 14:55:16 -05:00
193 changed files with 37726 additions and 8338 deletions

53
.env.example Normal file
View File

@@ -0,0 +1,53 @@
# ── ThreatHunt Configuration ──────────────────────────────────────────
# All backend env vars are prefixed with TH_ and match AppConfig field names.
# Copy this file to .env and adjust values.
# ── General ───────────────────────────────────────────────────────────
TH_DEBUG=false
# ── Database ──────────────────────────────────────────────────────────
# SQLite for local dev (zero-config):
TH_DATABASE_URL=sqlite+aiosqlite:///./threathunt.db
# PostgreSQL for production:
# TH_DATABASE_URL=postgresql+asyncpg://threathunt:password@localhost:5432/threathunt
# ── CORS ──────────────────────────────────────────────────────────────
TH_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8000
# ── File uploads ──────────────────────────────────────────────────────
TH_MAX_UPLOAD_SIZE_MB=500
# ── LLM Cluster (Wile & Roadrunner) ──────────────────────────────────
TH_OPENWEBUI_URL=https://ai.guapo613.beer
TH_OPENWEBUI_API_KEY=
TH_WILE_HOST=100.110.190.12
TH_WILE_OLLAMA_PORT=11434
TH_ROADRUNNER_HOST=100.110.190.11
TH_ROADRUNNER_OLLAMA_PORT=11434
# ── Default models (auto-selected by TaskRouter) ─────────────────────
TH_DEFAULT_FAST_MODEL=llama3.1:latest
TH_DEFAULT_HEAVY_MODEL=llama3.1:70b-instruct-q4_K_M
TH_DEFAULT_CODE_MODEL=qwen2.5-coder:32b
TH_DEFAULT_VISION_MODEL=llama3.2-vision:11b
TH_DEFAULT_EMBEDDING_MODEL=bge-m3:latest
# ── Agent behaviour ──────────────────────────────────────────────────
TH_AGENT_MAX_TOKENS=2048
TH_AGENT_TEMPERATURE=0.3
TH_AGENT_HISTORY_LENGTH=10
TH_FILTER_SENSITIVE_DATA=true
# ── Enrichment API keys (optional) ───────────────────────────────────
TH_VIRUSTOTAL_API_KEY=
TH_ABUSEIPDB_API_KEY=
TH_SHODAN_API_KEY=
# ── Auth ─────────────────────────────────────────────────────────────
TH_JWT_SECRET=CHANGE-ME-IN-PRODUCTION-USE-A-REAL-SECRET
TH_JWT_ACCESS_TOKEN_MINUTES=60
TH_JWT_REFRESH_TOKEN_DAYS=7
# ── Frontend ─────────────────────────────────────────────────────────
REACT_APP_API_URL=http://localhost:8000

83
.gitignore vendored
View File

@@ -1,67 +1,56 @@
# Python # ── Python ────────────────────────────────────
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
*.so
.Python
env/
venv/
ENV/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/ *.egg-info/
.installed.cfg dist/
build/
*.egg *.egg
.eggs/
# Node # ── Virtual environments ─────────────────────
node_modules/ venv/
npm-debug.log* .venv/
yarn-debug.log* env/
yarn-error.log*
.pnp
.pnp.js
# Testing # ── IDE / Editor ─────────────────────────────
.coverage
.pytest_cache/
htmlcov/
# IDE
.vscode/ .vscode/
.idea/ .idea/
*.swp *.swp
*.swo *.swo
*~ *~
# Environment # ── OS ────────────────────────────────────────
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Database # ── Environment / Secrets ────────────────────
.env
*.env.local
# ── Database ─────────────────────────────────
*.db *.db
*.sqlite
*.sqlite3 *.sqlite3
# Logs # ── Uploads ──────────────────────────────────
*.log uploads/
logs/
# Docker # ── Node / Frontend ──────────────────────────
*.pid node_modules/
frontend/build/
frontend/.env.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# ── Docker ───────────────────────────────────
docker-compose.override.yml
# ── Test / Coverage ──────────────────────────
.coverage
htmlcov/
.pytest_cache/
.mypy_cache/
# ── Alembic ──────────────────────────────────
alembic/versions/*.pyc

View File

@@ -1,472 +0,0 @@
# Architecture Documentation
This document describes the architecture and design decisions for VelociCompanion.
## System Overview
VelociCompanion is a multi-tenant, cloud-native threat hunting companion designed to work with Velociraptor. It provides secure authentication, data isolation, and role-based access control.
```
┌─────────────┐ ┌─────────────┐ ┌──────────────┐
│ │ │ │ │ │
│ Frontend │────▶│ Backend │────▶│ PostgreSQL │
│ (React) │ │ (FastAPI) │ │ Database │
│ │ │ │ │ │
└─────────────┘ └─────────────┘ └──────────────┘
┌─────────────┐
│ │
│ Velociraptor│
│ Servers │
│ │
└─────────────┘
```
## Technology Stack
### Backend
- **FastAPI**: Modern, fast web framework for building APIs
- **SQLAlchemy**: SQL toolkit and ORM
- **PostgreSQL**: Relational database
- **Alembic**: Database migration tool
- **Python-Jose**: JWT token handling
- **Passlib**: Password hashing with bcrypt
### Frontend
- **React**: UI library
- **TypeScript**: Type-safe JavaScript
- **Axios**: HTTP client
- **React Router**: Client-side routing
### Infrastructure
- **Docker**: Containerization
- **Docker Compose**: Multi-container orchestration
## Core Components
### 1. Authentication System
#### JWT Token Flow
```
1. User submits credentials (username/password)
2. Backend verifies credentials
3. Backend generates JWT token with:
- user_id (sub)
- tenant_id
- role
- expiration time
4. Frontend stores token in localStorage
5. All subsequent requests include token in Authorization header
6. Backend validates token and extracts user context
```
#### Password Security
- Passwords are hashed using bcrypt with automatic salt generation
- Password hashes are never exposed in API responses
- Plaintext passwords are never logged or stored
#### Token Security
- Tokens expire after 30 minutes (configurable)
- Tokens are signed with HS256 algorithm
- Secret key must be at least 32 characters
### 2. Multi-Tenancy
#### Data Isolation
Every database query is automatically scoped to the user's tenant:
```python
# Example: Listing hosts
hosts = db.query(Host).filter(Host.tenant_id == current_user.tenant_id).all()
```
#### Tenant Creation
- Default tenant is created automatically on first user registration
- Admin users can create additional tenants
- Users are assigned to exactly one tenant
#### Cross-Tenant Access
- Regular users: Can only access data in their tenant
- Admin users: Can access all data in their tenant
- Super-admin (future): Could access multiple tenants
### 3. Role-Based Access Control (RBAC)
#### Roles
- **user**: Standard user with read/write access to their tenant's data
- **admin**: Elevated privileges within their tenant
- Can manage users in their tenant
- Can create/modify/delete resources
- Can view all data in their tenant
#### Permission Enforcement
```python
# Endpoint requiring admin role
@router.get("/users")
async def list_users(
current_user: User = Depends(require_role(["admin"]))
):
# Only admins can access this
pass
```
### 4. Database Schema
#### Core Tables
**tenants**
- id (PK)
- name (unique)
- description
- created_at
**users**
- id (PK)
- username (unique)
- password_hash
- role
- tenant_id (FK → tenants)
- is_active
- created_at
**hosts**
- id (PK)
- hostname
- ip_address
- os
- tenant_id (FK → tenants)
- host_metadata (JSON)
- created_at
- last_seen
**cases**
- id (PK)
- title
- description
- status (open, closed, investigating)
- severity (low, medium, high, critical)
- tenant_id (FK → tenants)
- created_at
- updated_at
**artifacts**
- id (PK)
- artifact_type (hash, ip, domain, email, etc.)
- value
- description
- case_id (FK → cases)
- artifact_metadata (JSON)
- created_at
#### Relationships
```
tenants (1) ──< (N) users
tenants (1) ──< (N) hosts
tenants (1) ──< (N) cases
cases (1) ──< (N) artifacts
```
### 5. API Design
#### RESTful Principles
- Resources are nouns (users, hosts, cases)
- HTTP methods represent actions (GET, POST, PUT, DELETE)
- Proper status codes (200, 201, 401, 403, 404)
#### Authentication
All endpoints except `/auth/register` and `/auth/login` require authentication.
```
Authorization: Bearer <jwt_token>
```
#### Response Format
Success:
```json
{
"id": 1,
"username": "john",
"role": "user",
"tenant_id": 1
}
```
Error:
```json
{
"detail": "User not found"
}
```
### 6. Frontend Architecture
#### Component Structure
```
src/
├── components/ # Reusable UI components
│ └── PrivateRoute.tsx
├── context/ # React Context providers
│ └── AuthContext.tsx
├── pages/ # Page components
│ ├── Login.tsx
│ └── Dashboard.tsx
├── utils/ # Utilities
│ └── api.ts # API client
├── App.tsx # Main app component
└── index.tsx # Entry point
```
#### State Management
- **AuthContext**: Global authentication state
- Current user
- Login/logout functions
- Loading state
- Authentication status
#### Routing
```
/login → Login page (public)
/ → Dashboard (protected)
/* → Redirect to / (protected)
```
### 7. Security Architecture
#### Authentication Flow
1. Frontend sends credentials to `/api/auth/login`
2. Backend validates and returns JWT token
3. Frontend stores token in localStorage
4. Token included in all API requests
5. Backend validates token on each request
#### Authorization Flow
1. Extract JWT from Authorization header
2. Verify token signature and expiration
3. Extract user_id from token payload
4. Load user from database
5. Check user's role for endpoint access
6. Apply tenant scoping to queries
#### Security Headers
```python
# CORS configuration
allow_origins=["http://localhost:3000"]
allow_credentials=True
allow_methods=["*"]
allow_headers=["*"]
```
## Data Flow Examples
### User Registration
```
1. POST /api/auth/register
{username: "john", password: "pass123"}
2. Backend hashes password
3. Create default tenant if needed
4. Create user record
5. Return user object (without password_hash)
```
### Host Ingestion
```
1. Velociraptor sends data to POST /api/ingestion/ingest
- Must include valid JWT token
2. Extract tenant_id from current user
3. Find or create host with hostname
4. Update host metadata
5. Return success response
```
### Listing Resources
```
1. GET /api/hosts with Authorization header
2. Validate JWT token
3. Extract tenant_id from user
4. Query: SELECT * FROM hosts WHERE tenant_id = ?
5. Return filtered results
```
## Deployment Architecture
### Development
```
┌──────────────────────────────────────┐
│ Docker Compose │
├──────────────────────────────────────┤
│ Frontend:3000 Backend:8000 DB:5432│
└──────────────────────────────────────┘
```
### Production (Recommended)
```
┌─────────────┐ ┌─────────────┐
│ Nginx/ │ │ Frontend │
│ Traefik │────▶│ (Static) │
│ (HTTPS) │ └─────────────┘
└──────┬──────┘
┌─────────────┐ ┌──────────────┐
│ Backend │ │ PostgreSQL │
│ (Multiple │────▶│ (Managed) │
│ instances) │ └──────────────┘
└─────────────┘
```
## Performance Considerations
### Database Indexing
- Primary keys on all tables
- Unique index on usernames
- Index on tenant_id columns for fast filtering
- Index on hostname for host lookups
### Query Optimization
- Always filter by tenant_id early in queries
- Use pagination for large result sets (skip/limit)
- Lazy load relationships when not needed
### Caching (Future)
- Cache tenant information
- Cache user profiles
- Cache frequently accessed hosts
## Monitoring & Logging
### Health Checks
```
GET /health → {"status": "healthy"}
```
### Logging
- Request logging via Uvicorn
- Error tracking in application logs
- Database query logging (development only)
### Metrics (Future)
- Request count per endpoint
- Authentication success/failure rate
- Database query performance
- Active user count
## Migration Strategy
### Database Migrations
```bash
# Create migration
alembic revision --autogenerate -m "Description"
# Apply migration
alembic upgrade head
# Rollback
alembic downgrade -1
```
### Schema Evolution
1. Create migration for schema changes
2. Test migration in development
3. Apply to staging environment
4. Verify data integrity
5. Apply to production during maintenance window
## Testing Strategy
### Unit Tests (Future)
- Test individual functions
- Mock database connections
- Test password hashing
- Test JWT token creation/verification
### Integration Tests (Future)
- Test API endpoints
- Test authentication flow
- Test multi-tenancy isolation
- Test RBAC enforcement
### Manual Testing
- Use test_api.sh script
- Use FastAPI's /docs interface
- Test frontend authentication flow
## Future Enhancements
### Phase 2
- Refresh tokens for longer sessions
- Password reset functionality
- Email verification
- Two-factor authentication (2FA)
### Phase 3
- Audit logging
- Advanced search and filtering
- Real-time notifications
- Velociraptor direct integration
### Phase 4
- Machine learning for threat detection
- Automated playbooks
- Integration with SIEM systems
- Advanced reporting and analytics
## Troubleshooting Guide
### Common Issues
**Token Expired**
- Tokens expire after 30 minutes
- User must login again
- Consider implementing refresh tokens
**Permission Denied**
- User lacks required role
- Check user's role in database
- Verify endpoint requires correct role
**Data Not Visible**
- Check tenant_id of user
- Verify data belongs to correct tenant
- Ensure tenant_id is being applied to queries
**Database Connection Failed**
- Check DATABASE_URL environment variable
- Verify PostgreSQL is running
- Check network connectivity
## Development Guidelines
### Adding New Endpoints
1. Create route in `app/api/routes/`
2. Add authentication dependency
3. Apply tenant scoping to queries
4. Add role check if needed
5. Create Pydantic schemas
6. Update router registration in main.py
7. Test with /docs interface
### Adding New Models
1. Create model in `app/models/`
2. Add tenant_id foreign key
3. Create migration
4. Create Pydantic schemas
5. Create CRUD routes
6. Apply tenant scoping
### Code Style
- Follow PEP 8 for Python
- Use type hints
- Write docstrings for functions
- Keep functions small and focused
- Use meaningful variable names
## References
- [FastAPI Documentation](https://fastapi.tiangolo.com/)
- [SQLAlchemy Documentation](https://docs.sqlalchemy.org/)
- [JWT RFC](https://tools.ietf.org/html/rfc7519)
- [OAuth 2.0 RFC](https://tools.ietf.org/html/rfc6749)

View File

@@ -1,311 +0,0 @@
# Deployment Checklist
Use this checklist to deploy VelociCompanion to production.
## Pre-Deployment
### Security Review
- [ ] Generate new SECRET_KEY (minimum 32 characters, cryptographically random)
- [ ] Update DATABASE_URL with production credentials
- [ ] Use strong database password (not default postgres/postgres)
- [ ] Review CORS settings in `backend/app/main.py`
- [ ] Enable HTTPS/TLS for all communications
- [ ] Configure firewall rules
- [ ] Set up VPN or IP whitelist for database access
### Configuration
- [ ] Create production `.env` file
- [ ] Set ACCESS_TOKEN_EXPIRE_MINUTES appropriately (30 minutes recommended)
- [ ] Configure frontend REACT_APP_API_URL
- [ ] Review all environment variables
- [ ] Set up backup strategy for database
### Infrastructure
- [ ] Provision database server or use managed service (RDS, Cloud SQL, etc.)
- [ ] Set up load balancer for backend
- [ ] Configure CDN for frontend static files
- [ ] Set up monitoring and alerting
- [ ] Configure log aggregation
- [ ] Set up automated backups
## Deployment Steps
### 1. Database Setup
```bash
# Create production database
createdb velocicompanion_prod
# Set environment variable
export DATABASE_URL="postgresql://user:pass@host:5432/velocicompanion_prod"
# Run migrations
cd backend
alembic upgrade head
```
### 2. Backend Deployment
```bash
# Build production image
docker build -t velocicompanion-backend:latest ./backend
# Or deploy with docker-compose
docker-compose -f docker-compose.prod.yml up -d backend
```
### 3. Frontend Deployment
```bash
# Build production bundle
cd frontend
npm install
npm run build
# Deploy build/ directory to CDN or web server
# Update API URL in environment
```
### 4. Create Initial Admin User
```bash
# Register first admin user via API
curl -X POST https://your-domain.com/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"username": "admin",
"password": "STRONG_PASSWORD_HERE",
"role": "admin"
}'
```
### 5. Verify Deployment
```bash
# Check health endpoint
curl https://your-domain.com/health
# Expected: {"status":"healthy"}
# Test authentication
curl -X POST https://your-domain.com/api/auth/login \
-d "username=admin&password=YOUR_PASSWORD"
# Should return JWT token
```
## Post-Deployment
### Monitoring Setup
- [ ] Configure application monitoring (e.g., Prometheus, Datadog)
- [ ] Set up uptime monitoring (e.g., Pingdom, UptimeRobot)
- [ ] Configure error tracking (e.g., Sentry)
- [ ] Set up log analysis (e.g., ELK stack, CloudWatch)
- [ ] Create dashboards for key metrics
### Alerts
- [ ] High error rate alert
- [ ] Slow response time alert
- [ ] Database connection issues
- [ ] High CPU/memory usage
- [ ] Failed authentication attempts
- [ ] Disk space low
### Backup Verification
- [ ] Verify automated backups are running
- [ ] Test backup restoration process
- [ ] Document backup/restore procedures
- [ ] Set up backup retention policy
### Security
- [ ] Run security scan
- [ ] Review access logs
- [ ] Enable rate limiting
- [ ] Set up intrusion detection
- [ ] Configure SSL certificate auto-renewal
### Documentation
- [ ] Update production endpoints in documentation
- [ ] Document deployment process
- [ ] Create runbook for common issues
- [ ] Train operations team
- [ ] Update architecture diagrams
## Production Environment Variables
### Backend (.env)
```bash
DATABASE_URL=postgresql://user:strongpass@db-host:5432/velocicompanion
SECRET_KEY=your-32-plus-character-secret-key-here-make-it-random
ACCESS_TOKEN_EXPIRE_MINUTES=30
ALGORITHM=HS256
```
### Frontend
```bash
REACT_APP_API_URL=https://api.your-domain.com
```
## Load Balancer Configuration
### Backend
```nginx
upstream backend {
server backend1:8000;
server backend2:8000;
server backend3:8000;
}
server {
listen 443 ssl;
server_name api.your-domain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
### Frontend
```nginx
server {
listen 443 ssl;
server_name your-domain.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
root /var/www/velocicompanion/build;
index index.html;
location / {
try_files $uri /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
```
## Scaling Considerations
### Horizontal Scaling
- Run multiple backend instances behind load balancer
- Use managed PostgreSQL with read replicas
- Serve frontend from CDN
- Implement caching layer (Redis)
### Vertical Scaling
- **Database**: 4GB RAM minimum, 8GB+ for production
- **Backend**: 2GB RAM per instance, 2+ CPU cores
- **Frontend**: Static files, minimal resources
### Performance Optimization
- [ ] Enable database connection pooling
- [ ] Add Redis cache for sessions
- [ ] Implement request rate limiting
- [ ] Optimize database queries
- [ ] Add database indexes
- [ ] Enable GZIP compression
## Disaster Recovery
### Backup Strategy
- **Database**: Daily full backups, hourly incremental
- **Files**: Daily backup of configuration files
- **Retention**: 30 days of backups
- **Off-site**: Copy backups to different region
### Recovery Procedures
1. Restore database from latest backup
2. Deploy latest application version
3. Run database migrations if needed
4. Verify system functionality
5. Update DNS if needed
### RTO/RPO
- **RTO** (Recovery Time Objective): 4 hours
- **RPO** (Recovery Point Objective): 1 hour
## Maintenance
### Regular Tasks
- [ ] Review logs weekly
- [ ] Update dependencies monthly
- [ ] Security patches: Apply within 7 days
- [ ] Database optimization quarterly
- [ ] Review and rotate access credentials quarterly
### Update Process
1. Test updates in staging environment
2. Schedule maintenance window
3. Notify users of planned downtime
4. Create backup before update
5. Deploy updates
6. Run smoke tests
7. Monitor for issues
## Rollback Plan
If deployment fails:
1. **Immediate**
```bash
# Rollback to previous version
docker-compose down
git checkout <previous-tag>
docker-compose up -d
```
2. **Database Rollback**
```bash
# Rollback migration
alembic downgrade -1
```
3. **Verify**
- Check health endpoint
- Test critical paths
- Review error logs
## Support Contacts
- **Technical Lead**: [Contact Info]
- **Database Admin**: [Contact Info]
- **Security Team**: [Contact Info]
- **On-Call**: [Rotation Schedule]
## Success Criteria
- [ ] All services running and healthy
- [ ] Users can login successfully
- [ ] API response times < 500ms
- [ ] Error rate < 1%
- [ ] Database queries optimized
- [ ] Backups running successfully
- [ ] Monitoring and alerts active
- [ ] Documentation updated
- [ ] Team trained on operations
## Sign-Off
- [ ] Technical Lead Approval
- [ ] Security Team Approval
- [ ] Operations Team Approval
- [ ] Product Owner Approval
---
**Deployment Date**: _______________
**Deployed By**: _______________
**Sign-Off**: _______________

32
Dockerfile.backend Normal file
View File

@@ -0,0 +1,32 @@
# ThreatHunt Backend API - Python 3.13
FROM python:3.13-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements
COPY backend/requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy backend code
COPY backend/ .
# Create non-root user & data directory
RUN useradd -m -u 1000 appuser && mkdir -p /app/data && chown -R appuser:appuser /app
USER appuser
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8000/ || exit 1
# Run Alembic migrations then start Uvicorn
CMD ["sh", "-c", "python -m alembic upgrade head && python run.py"]

36
Dockerfile.frontend Normal file
View File

@@ -0,0 +1,36 @@
# ThreatHunt Frontend - Node.js React
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY frontend/package.json frontend/package-lock.json* ./
# Install dependencies
RUN npm ci
# Copy source
COPY frontend/public ./public
COPY frontend/src ./src
COPY frontend/tsconfig.json ./
# Build application
RUN npm run build
# Production stage — nginx reverse-proxy + static files
FROM nginx:alpine
# Copy built React app
COPY --from=builder /app/build /usr/share/nginx/html
# Copy custom nginx config (proxies /api to backend)
COPY frontend/nginx.conf /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD wget --quiet --tries=1 --spider http://localhost:3000/ || exit 1
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,357 +0,0 @@
# Implementation Summary: Phase 1 - Core Infrastructure & Auth
## Overview
This document summarizes the complete implementation of Phase 1 for VelociCompanion, a multi-tenant threat hunting companion for Velociraptor. All acceptance criteria have been met.
## What Was Built
### 🎯 Complete Backend API (FastAPI)
#### Core Infrastructure
- ✅ FastAPI application with 22 routes
- ✅ PostgreSQL database integration via SQLAlchemy
- ✅ Alembic database migrations configured
- ✅ Docker containerization with health checks
- ✅ Environment-based configuration
#### Authentication System
- ✅ JWT token-based authentication using python-jose
- ✅ Password hashing with bcrypt (passlib)
- ✅ OAuth2 password flow for API compatibility
- ✅ Token expiration and validation
- ✅ Secure credential handling
#### Database Models (5 tables)
1. **tenants** - Multi-tenant organization data
2. **users** - User accounts with roles
3. **hosts** - Monitored systems
4. **cases** - Threat hunting investigations
5. **artifacts** - IOCs and evidence
#### API Endpoints (22 routes)
**Authentication (`/api/auth`)**
- `POST /register` - Create new user account
- `POST /login` - Authenticate and receive JWT
- `GET /me` - Get current user profile
- `PUT /me` - Update user profile
**User Management (`/api/users`)** - Admin only
- `GET /` - List users in tenant
- `GET /{user_id}` - Get user details
- `PUT /{user_id}` - Update user
- `DELETE /{user_id}` - Deactivate user
**Tenants (`/api/tenants`)**
- `GET /` - List accessible tenants
- `POST /` - Create tenant (admin)
- `GET /{tenant_id}` - Get tenant details
**Hosts (`/api/hosts`)**
- `GET /` - List hosts (tenant-scoped)
- `POST /` - Create host
- `GET /{host_id}` - Get host details
**Ingestion (`/api/ingestion`)**
- `POST /ingest` - Ingest Velociraptor data
**VirusTotal (`/api/vt`)**
- `POST /lookup` - Hash reputation lookup
#### Security Features
- ✅ Role-based access control (user, admin)
- ✅ Multi-tenant data isolation
- ✅ Automatic tenant scoping on all queries
- ✅ Password strength enforcement
- ✅ Protected routes with authentication
- ✅ 0 security vulnerabilities (CodeQL verified)
### 🎨 Complete Frontend (React + TypeScript)
#### Core Components
- ✅ React 18 with TypeScript
- ✅ React Router for navigation
- ✅ Axios for API communication
- ✅ Context API for state management
#### Pages
1. **Login Page** - Full authentication form
2. **Dashboard** - Protected home page with user info
3. **Private Routes** - Authentication-protected routing
#### Features
- ✅ JWT token storage in localStorage
- ✅ Automatic token inclusion in API requests
- ✅ 401 error handling with auto-redirect
- ✅ Loading states during authentication
- ✅ Clean, responsive UI design
### 📦 Infrastructure & DevOps
#### Docker Configuration
- ✅ Multi-container Docker Compose setup
- ✅ PostgreSQL with health checks
- ✅ Backend with automatic migrations
- ✅ Frontend with hot reload
- ✅ Volume mounts for persistence
#### Documentation
1. **README.md** - Project overview and features
2. **QUICKSTART.md** - Step-by-step setup guide
3. **ARCHITECTURE.md** - System design and technical details
4. **IMPLEMENTATION_SUMMARY.md** - This document
#### Testing & Validation
-`test_api.sh` - Automated API testing script
- ✅ Manual testing procedures documented
- ✅ OpenAPI/Swagger documentation at `/docs`
- ✅ Health check endpoint
## File Structure
```
ThreatHunt/
├── backend/
│ ├── alembic/ # Database migrations
│ │ ├── versions/
│ │ │ └── f82b3092d056_initial_migration.py
│ │ └── env.py
│ ├── app/
│ │ ├── api/routes/ # API endpoints
│ │ │ ├── auth.py # Authentication
│ │ │ ├── users.py # User management
│ │ │ ├── tenants.py # Tenant management
│ │ │ ├── hosts.py # Host management
│ │ │ ├── ingestion.py # Data ingestion
│ │ │ └── vt.py # VirusTotal
│ │ ├── core/ # Core functionality
│ │ │ ├── config.py # Settings
│ │ │ ├── database.py # DB connection
│ │ │ ├── security.py # JWT & passwords
│ │ │ └── deps.py # FastAPI dependencies
│ │ ├── models/ # SQLAlchemy models
│ │ │ ├── user.py
│ │ │ ├── tenant.py
│ │ │ ├── host.py
│ │ │ ├── case.py
│ │ │ └── artifact.py
│ │ ├── schemas/ # Pydantic schemas
│ │ │ ├── auth.py
│ │ │ └── user.py
│ │ └── main.py # FastAPI app
│ ├── requirements.txt # Python dependencies
│ ├── Dockerfile
│ └── .env.example
├── frontend/
│ ├── src/
│ │ ├── components/
│ │ │ └── PrivateRoute.tsx # Auth wrapper
│ │ ├── context/
│ │ │ └── AuthContext.tsx # Auth state
│ │ ├── pages/
│ │ │ ├── Login.tsx # Login form
│ │ │ └── Dashboard.tsx # Home page
│ │ ├── utils/
│ │ │ └── api.ts # API client
│ │ ├── App.tsx # Main component
│ │ └── index.tsx # Entry point
│ ├── public/
│ │ └── index.html
│ ├── package.json
│ ├── tsconfig.json
│ └── Dockerfile
├── docker-compose.yml # Container orchestration
├── test_api.sh # API test script
├── .gitignore
├── README.md
├── QUICKSTART.md
├── ARCHITECTURE.md
└── IMPLEMENTATION_SUMMARY.md
```
## Acceptance Criteria Status
| Criterion | Status | Evidence |
|-----------|--------|----------|
| Users can register with username/password | ✅ PASS | `POST /api/auth/register` endpoint |
| Users can login and receive JWT token | ✅ PASS | `POST /api/auth/login` returns JWT |
| Protected routes require valid JWT | ✅ PASS | All routes use `get_current_user` dependency |
| Users can only access data within their tenant | ✅ PASS | All queries filtered by `tenant_id` |
| Admin users can manage other users | ✅ PASS | `/api/users` routes with `require_role(["admin"])` |
| Alembic migrations are set up and working | ✅ PASS | Initial migration created and tested |
| Frontend has basic login flow | ✅ PASS | Login page with AuthContext integration |
| All existing functionality continues to work | ✅ PASS | All routes require auth, tenant scoping applied |
## Technical Achievements
### Security
- **Zero vulnerabilities** detected by CodeQL scanner
- Modern cryptographic practices (bcrypt, HS256)
- Secure token handling and storage
- Protection against common attacks (SQL injection, XSS)
### Code Quality
- **Type safety** with TypeScript and Python type hints
- **Clean architecture** with separation of concerns
- **RESTful API design** following best practices
- **Comprehensive documentation** for developers
### Performance
- **Database indexing** on key columns
- **Efficient queries** with proper filtering
- **Fast authentication** with JWT (stateless)
- **Health checks** for monitoring
## How to Use
### Quick Start
```bash
# 1. Start services
docker-compose up -d
# 2. Register a user
curl -X POST http://localhost:8000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"username": "admin", "password": "admin123", "role": "admin"}'
# 3. Login via frontend
open http://localhost:3000
# 4. Or login via API
curl -X POST http://localhost:8000/api/auth/login \
-d "username=admin&password=admin123"
# 5. Test all endpoints
./test_api.sh
```
### API Documentation
Interactive API docs available at:
- Swagger UI: http://localhost:8000/docs
- ReDoc: http://localhost:8000/redoc
## What's Next (Future Phases)
### Phase 2 - Enhanced Authentication
- Refresh tokens for longer sessions
- Password reset functionality
- Two-factor authentication (2FA)
- Session management
- Audit logging
### Phase 3 - Advanced Features
- Real-time notifications
- WebSocket support
- Advanced search and filtering
- Report generation
- Case collaboration features
### Phase 4 - Integrations
- Direct Velociraptor integration
- SIEM system connectors
- Threat intelligence feeds
- Automated response playbooks
- ML-based threat detection
## Migration from Development to Production
### Before Going Live
1. **Security Hardening**
- Generate secure SECRET_KEY (32+ chars)
- Use strong database passwords
- Enable HTTPS/TLS
- Configure proper CORS origins
- Review and restrict network access
2. **Database**
- Use managed PostgreSQL service
- Configure backups
- Set up replication
- Monitor performance
3. **Application**
- Set up load balancer
- Deploy multiple backend instances
- Configure logging aggregation
- Set up monitoring and alerts
4. **Frontend**
- Build production bundle
- Serve via CDN
- Enable caching
- Minify assets
## Support & Maintenance
### Logs
```bash
# View all logs
docker-compose logs -f
# Backend logs
docker-compose logs -f backend
# Database logs
docker-compose logs -f db
```
### Database Migrations
```bash
# Create migration
cd backend
alembic revision --autogenerate -m "Description"
# Apply migrations
alembic upgrade head
# Rollback
alembic downgrade -1
```
### Troubleshooting
See QUICKSTART.md for common issues and solutions.
## Metrics
### Code Statistics
- **Backend**: 29 Python files, ~2,000 lines
- **Frontend**: 8 TypeScript/TSX files, ~800 lines
- **Infrastructure**: 3 Dockerfiles, 1 docker-compose.yml
- **Documentation**: 4 comprehensive guides
- **Total**: ~50 files across the stack
### Features Delivered
- 22 API endpoints
- 5 database models
- 1 database migration
- 2 frontend pages
- 4 React components/contexts
- 100% authentication coverage
- 100% tenant isolation
- 0 security vulnerabilities
## Conclusion
Phase 1 of VelociCompanion has been successfully completed with all acceptance criteria met. The system provides a solid foundation for multi-tenant threat hunting operations with:
-**Secure authentication** with JWT tokens
-**Complete data isolation** between tenants
-**Role-based access control** for permissions
-**Modern tech stack** (FastAPI, React, PostgreSQL)
-**Production-ready infrastructure** with Docker
-**Comprehensive documentation** for users and developers
The system is ready for:
1. Integration with Velociraptor servers
2. Deployment to staging/production environments
3. User acceptance testing
4. Development of Phase 2 features
## Credits
Implemented by: GitHub Copilot
Repository: https://github.com/mblanke/ThreatHunt
Date: December 2025
Version: 0.1.0

View File

@@ -1,578 +0,0 @@
# Phase 5: Distributed LLM Routing Architecture
## Overview
Phase 5 introduces a sophisticated distributed Large Language Model (LLM) routing system that intelligently classifies tasks and routes them to specialized models across multiple GPU nodes (GB10 devices). This architecture enables efficient utilization of computational resources and optimal model selection based on task requirements.
## Architecture Components
The system consists of four containerized components that work together to provide intelligent, scalable LLM processing:
### 1. Router Agent (LLM Classifier + Policy Engine)
**Module**: `app/core/llm_router.py`
The Router Agent is responsible for:
- **Request Classification**: Analyzes incoming requests to determine the task type
- **Model Selection**: Routes requests to the most appropriate specialized model
- **Policy Enforcement**: Applies routing rules based on configured policies
**Task Types & Model Routing:**
| Task Type | Model | Use Case |
|-----------|-------|----------|
| `general_reasoning` | DeepSeek | Complex analysis and reasoning |
| `multilingual` | Qwen72 / Aya | Translation and multilingual tasks |
| `structured_parsing` | Phi-4 | Structured data extraction |
| `rule_generation` | Qwen-Coder | Code and rule generation |
| `adversarial_reasoning` | LLaMA 3.1 | Threat and adversarial analysis |
| `classification` | Granite Guardian | Pure classification tasks |
**Classification Logic:**
```python
from app.core.llm_router import get_llm_router
router = get_llm_router()
routing_decision = router.route_request({
"prompt": "Analyze this threat...",
"task_hints": ["threat", "adversary"]
})
# Routes to LLaMA 3.1 for adversarial reasoning
```
### 2. Job Scheduler (GPU Load Balancer)
**Module**: `app/core/job_scheduler.py`
The Job Scheduler manages:
- **Node Selection**: Determines which GB10 device is available
- **Resource Monitoring**: Tracks GPU VRAM and compute utilization
- **Parallelization Decisions**: Determines if jobs should be distributed
- **Serial Chaining**: Handles multi-step reasoning workflows
**GPU Node Configuration:**
**GB10 Node 1** (`gb10-node-1:8001`)
- **Total VRAM**: 80 GB
- **Models Loaded**: DeepSeek, Qwen72
- **Primary Use**: General reasoning and multilingual tasks
**GB10 Node 2** (`gb10-node-2:8001`)
- **Total VRAM**: 80 GB
- **Models Loaded**: Phi-4, Qwen-Coder, LLaMA 3.1, Granite Guardian
- **Primary Use**: Specialized tasks (parsing, coding, classification, threat analysis)
**Scheduling Strategies:**
1. **Single Node Execution**
- Default for simple requests
- Selected based on lowest compute utilization
- Requires sufficient VRAM for model
2. **Parallel Execution**
- Distributes work across multiple nodes
- Used for batch processing or high-priority jobs
- Automatic load balancing
3. **Serial Chaining**
- Multi-step dependent operations
- Sequential execution with context passing
- Used for complex reasoning workflows
4. **Queued Execution**
- When all nodes are at capacity
- Priority-based queue management
- Automatic dispatch when resources available
**Example Usage:**
```python
from app.core.job_scheduler import get_job_scheduler, Job
scheduler = get_job_scheduler()
job = Job(
job_id="threat_analysis_001",
model="llama31",
priority=1,
estimated_vram_gb=10,
requires_parallel=False,
requires_chaining=False,
payload={"prompt": "..."}
)
scheduling_decision = await scheduler.schedule_job(job)
# Returns node assignment and execution mode
```
### 3. LLM Pool (OpenAI-Compatible Endpoints)
**Module**: `app/core/llm_pool.py`
The LLM Pool provides:
- **Unified Interface**: OpenAI-compatible API for all models
- **Endpoint Management**: Tracks availability and health
- **Parallel Execution**: Simultaneous multi-model requests
- **Error Handling**: Graceful fallback on failures
**Available Endpoints:**
| Model | Endpoint | Node | Specialization |
|-------|----------|------|----------------|
| DeepSeek | `http://gb10-node-1:8001/deepseek` | Node 1 | General reasoning |
| Qwen72 | `http://gb10-node-1:8001/qwen72` | Node 1 | Multilingual |
| Phi-4 | `http://gb10-node-2:8001/phi4` | Node 2 | Structured parsing |
| Qwen-Coder | `http://gb10-node-2:8001/qwen-coder` | Node 2 | Code generation |
| LLaMA 3.1 | `http://gb10-node-2:8001/llama31` | Node 2 | Adversarial reasoning |
| Granite Guardian | `http://gb10-node-2:8001/granite-guardian` | Node 2 | Classification |
**Example Usage:**
```python
from app.core.llm_pool import get_llm_pool
pool = get_llm_pool()
# Single model call
result = await pool.call_model(
model_name="llama31",
prompt="Analyze this threat pattern...",
parameters={"temperature": 0.7, "max_tokens": 2048}
)
# Multiple models in parallel
results = await pool.call_multiple_models(
model_names=["llama31", "deepseek"],
prompt="Complex threat analysis...",
parameters={"temperature": 0.7}
)
```
### 4. Merger Agent (Result Synthesizer)
**Module**: `app/core/merger_agent.py`
The Merger Agent provides:
- **Result Combination**: Intelligently merges outputs from multiple models
- **Strategy Selection**: Multiple merging strategies for different use cases
- **Quality Assessment**: Evaluates and ranks responses
- **Consensus Building**: Determines agreement across models
**Merging Strategies:**
1. **Consensus** (`MergeStrategy.CONSENSUS`)
- Takes majority vote for classifications
- Selects most common response
- Best for: Classification tasks, binary decisions
2. **Weighted** (`MergeStrategy.WEIGHTED`)
- Weights results by confidence scores
- Selects highest confidence response
- Best for: When models provide confidence scores
3. **Concatenate** (`MergeStrategy.CONCATENATE`)
- Combines all responses sequentially
- Preserves all information
- Best for: Comprehensive analysis requiring multiple perspectives
4. **Best Quality** (`MergeStrategy.BEST_QUALITY`)
- Selects highest quality response based on metrics
- Considers length, completeness, formatting
- Best for: Text generation, detailed explanations
5. **Ensemble** (`MergeStrategy.ENSEMBLE`)
- Synthesizes insights from all models
- Creates comprehensive summary
- Best for: Complex analysis requiring synthesis
**Example Usage:**
```python
from app.core.merger_agent import get_merger_agent, MergeStrategy
merger = get_merger_agent()
# Multiple model results
results = [
{"model": "llama31", "response": "...", "confidence": 0.9},
{"model": "deepseek", "response": "...", "confidence": 0.85}
]
# Merge with consensus strategy
merged = merger.merge_results(results, strategy=MergeStrategy.CONSENSUS)
```
## API Endpoints
### Process LLM Request
```http
POST /api/llm/process
```
Processes a request through the complete routing system.
**Request Body:**
```json
{
"prompt": "Analyze this threat pattern for indicators of compromise",
"task_hints": ["threat", "adversary"],
"requires_parallel": false,
"requires_chaining": false,
"parameters": {
"temperature": 0.7,
"max_tokens": 2048
}
}
```
**Response:**
```json
{
"job_id": "job_123_4567",
"status": "completed",
"routing": {
"task_type": "adversarial_reasoning",
"model": "llama31",
"endpoint": "llama31",
"priority": 1
},
"scheduling": {
"job_id": "job_123_4567",
"execution_mode": "single",
"node": {
"node_id": "gb10-node-2",
"endpoint": "http://gb10-node-2:8001/llama31"
}
},
"result": {
"choices": [...]
},
"execution_mode": "single"
}
```
### List Available Models
```http
GET /api/llm/models
```
Returns all available LLM models in the pool.
**Response:**
```json
{
"models": [
{
"model_name": "deepseek",
"node_id": "gb10-node-1",
"endpoint_url": "http://gb10-node-1:8001/deepseek",
"is_available": true
},
...
],
"total": 6
}
```
### List GPU Nodes
```http
GET /api/llm/nodes
```
Returns status of all GPU nodes.
**Response:**
```json
{
"nodes": [
{
"node_id": "gb10-node-1",
"hostname": "gb10-node-1",
"vram_total_gb": 80,
"vram_used_gb": 25,
"vram_available_gb": 55,
"compute_utilization": 0.35,
"status": "available",
"models_loaded": ["deepseek", "qwen72"]
},
...
],
"available_count": 2
}
```
### Update Node Status (Admin Only)
```http
POST /api/llm/nodes/status
```
Updates GPU node status metrics.
**Request Body:**
```json
{
"node_id": "gb10-node-1",
"vram_used_gb": 30,
"compute_utilization": 0.45,
"status": "available"
}
```
### Get Routing Rules
```http
GET /api/llm/routing/rules
```
Returns current routing rules for task classification.
### Test Classification
```http
POST /api/llm/test-classification
```
Tests task classification without executing the request.
## Usage Examples
### Example 1: Threat Analysis with Adversarial Reasoning
```python
import httpx
async def analyze_threat():
async with httpx.AsyncClient() as client:
response = await client.post(
"http://localhost:8000/api/llm/process",
headers={"Authorization": f"Bearer {token}"},
json={
"prompt": "Analyze this suspicious PowerShell script for malicious intent...",
"task_hints": ["threat", "adversary", "malicious"],
"parameters": {"temperature": 0.3} # Lower temp for analysis
}
)
result = response.json()
print(f"Model used: {result['routing']['model']}")
print(f"Analysis: {result['result']}")
```
### Example 2: Code Generation for YARA Rules
```python
async def generate_yara_rule():
async with httpx.AsyncClient() as client:
response = await client.post(
"http://localhost:8000/api/llm/process",
headers={"Authorization": f"Bearer {token}"},
json={
"prompt": "Generate a YARA rule to detect this malware family...",
"task_hints": ["code", "rule", "generate"],
"parameters": {"temperature": 0.5}
}
)
result = response.json()
# Routes to Qwen-Coder automatically
print(f"Generated rule: {result['result']}")
```
### Example 3: Parallel Processing for Batch Analysis
```python
async def batch_analysis():
async with httpx.AsyncClient() as client:
response = await client.post(
"http://localhost:8000/api/llm/process",
headers={"Authorization": f"Bearer {token}"},
json={
"prompt": "Analyze these 50 log entries for anomalies...",
"task_hints": ["classify", "anomaly"],
"requires_parallel": True,
"batch_size": 50
}
)
result = response.json()
# Automatically parallelized across both nodes
print(f"Execution mode: {result['execution_mode']}")
```
### Example 4: Serial Chaining for Multi-Step Analysis
```python
async def chained_analysis():
async with httpx.AsyncClient() as client:
response = await client.post(
"http://localhost:8000/api/llm/process",
headers={"Authorization": f"Bearer {token}"},
json={
"prompt": "First extract IOCs, then classify threats, finally generate response plan",
"task_hints": ["parse", "classify", "generate"],
"requires_chaining": True,
"operations": ["extract", "classify", "generate"]
}
)
result = response.json()
# Executed serially with context passing
print(f"Chain result: {result['result']}")
```
## Integration with Existing Features
### Integration with Threat Intelligence (Phase 4)
The distributed LLM system enhances threat intelligence analysis:
```python
from app.core.threat_intel import get_threat_analyzer
from app.core.llm_pool import get_llm_pool
async def enhanced_threat_analysis(host_id):
# Step 1: Traditional ML analysis
analyzer = get_threat_analyzer()
ml_result = analyzer.analyze_host(host_data)
# Step 2: LLM-based deep analysis if score is concerning
if ml_result["score"] > 0.6:
pool = get_llm_pool()
llm_result = await pool.call_model(
"llama31",
f"Deep analysis of threat with score {ml_result['score']}: {host_data}",
{"temperature": 0.3}
)
return {
"ml_analysis": ml_result,
"llm_analysis": llm_result,
"recommendation": "quarantine" if ml_result["score"] > 0.8 else "investigate"
}
```
### Integration with Automated Playbooks (Phase 4)
LLM routing can trigger automated responses:
```python
from app.core.playbook_engine import get_playbook_engine
async def llm_triggered_playbook(threat_analysis):
if threat_analysis["result"]["severity"] == "critical":
engine = get_playbook_engine()
await engine.execute_playbook(
playbook={
"actions": [
{"type": "isolate_host", "params": {"host_id": host_id}},
{"type": "send_notification", "params": {"message": "Critical threat detected"}},
{"type": "create_case", "params": {"title": "Auto-generated from LLM analysis"}}
]
},
context=threat_analysis
)
```
## Deployment
### Docker Compose Configuration
Add LLM node services to `docker-compose.yml`:
```yaml
services:
# Existing services...
llm-node-1:
image: vllm/vllm-openai:latest
ports:
- "8001:8001"
environment:
- NVIDIA_VISIBLE_DEVICES=0,1
volumes:
- ./models:/models
command: >
--model /models/deepseek
--host 0.0.0.0
--port 8001
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 2
capabilities: [gpu]
llm-node-2:
image: vllm/vllm-openai:latest
ports:
- "8002:8001"
environment:
- NVIDIA_VISIBLE_DEVICES=2,3
volumes:
- ./models:/models
command: >
--model /models/phi4
--host 0.0.0.0
--port 8001
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 2
capabilities: [gpu]
```
### Environment Variables
Add to `.env`:
```bash
# Phase 5: LLM Configuration
LLM_NODE_1_URL=http://gb10-node-1:8001
LLM_NODE_2_URL=http://gb10-node-2:8001
LLM_ENABLE_PARALLEL=true
LLM_MAX_PARALLEL_JOBS=4
LLM_DEFAULT_TIMEOUT=60
```
## Performance Considerations
### Resource Allocation
- **DeepSeek**: ~40GB VRAM (high priority)
- **Qwen72**: ~35GB VRAM (medium priority)
- **Phi-4**: ~15GB VRAM (fast inference)
- **Qwen-Coder**: ~20GB VRAM
- **LLaMA 3.1**: ~25GB VRAM
- **Granite Guardian**: ~10GB VRAM (classification only)
### Load Balancing
The scheduler automatically:
- Monitors VRAM usage on each node
- Tracks compute utilization (0.0-1.0)
- Routes requests to less loaded nodes
- Queues jobs when capacity is reached
### Optimization Tips
1. **Use task_hints**: Helps router select optimal model faster
2. **Enable parallelization**: For batch jobs over 10 items
3. **Monitor node status**: Use `/api/llm/nodes` endpoint
4. **Set appropriate temperatures**: Lower (0.3) for analysis, higher (0.7) for generation
5. **Leverage caching**: Repeated prompts hit cache layer
## Security
- All LLM endpoints require authentication
- Admin-only node status updates
- Tenant isolation maintained
- Audit logging for all LLM requests
- Rate limiting per user/tenant
## Future Enhancements
- [ ] Model fine-tuning pipeline
- [ ] Custom model deployment
- [ ] Advanced caching layer
- [ ] Multi-region deployment
- [ ] Real-time model swapping
- [ ] Automated model selection via meta-learning
- [ ] Integration with external model APIs (OpenAI, Anthropic)
- [ ] Cost tracking and optimization
## Conclusion
Phase 5 provides a production-ready distributed LLM routing architecture that intelligently manages computational resources while optimizing for task-specific model selection. The system integrates seamlessly with existing threat hunting capabilities to provide enhanced analysis and automated decision-making.

View File

@@ -1,380 +0,0 @@
# Phases 2, 3, and 4 Implementation Complete
All requested phases have been successfully implemented and are ready for use.
## Overview
VelociCompanion v1.0.0 is now a complete, production-ready multi-tenant threat hunting platform with:
- Advanced authentication (2FA, refresh tokens, password reset)
- Real-time notifications via WebSocket
- Direct Velociraptor integration
- ML-powered threat detection
- Automated response playbooks
- Advanced reporting capabilities
## Phase 2: Enhanced Authentication ✅
### Implemented Features
#### Refresh Tokens
- 30-day expiration refresh tokens
- Secure token generation with `secrets.token_urlsafe()`
- Revocation support
- **Endpoint**: `POST /api/auth/refresh`
#### Two-Factor Authentication (2FA)
- TOTP-based 2FA using pyotp
- QR code generation for authenticator apps
- **Endpoints**:
- `POST /api/auth/2fa/setup` - Generate secret and QR code
- `POST /api/auth/2fa/verify` - Enable 2FA with code verification
- `POST /api/auth/2fa/disable` - Disable 2FA (requires code)
- Integrated into login flow
#### Password Reset
- Secure token-based password reset
- 1-hour token expiration
- **Endpoints**:
- `POST /api/auth/password-reset/request` - Request reset (email)
- `POST /api/auth/password-reset/confirm` - Confirm with token
#### Email Verification
- Email field added to User model
- `email_verified` flag for future verification flow
- Ready for email verification implementation
#### Audit Logging
- Comprehensive audit trail for all actions
- Tracks: user_id, tenant_id, action, resource_type, resource_id, IP, user agent
- **Endpoints**:
- `GET /api/audit` - List audit logs (admin only)
- `GET /api/audit/{id}` - Get specific audit log
- Filterable by action, resource type, date range
### Database Changes
- `refresh_tokens` table
- `password_reset_tokens` table
- `audit_logs` table
- User model: added `email`, `email_verified`, `totp_secret`, `totp_enabled`
## Phase 3: Advanced Features ✅
### Implemented Features
#### Advanced Search & Filtering
- Enhanced `GET /api/hosts` endpoint with:
- Hostname filtering (ILIKE pattern matching)
- IP address filtering
- OS filtering
- Dynamic sorting (any field, asc/desc)
- Pagination support
#### Real-time Notifications
- WebSocket-based real-time notifications
- Persistent notification storage
- **Endpoints**:
- `WS /api/notifications/ws` - WebSocket connection
- `GET /api/notifications` - List notifications
- `PUT /api/notifications/{id}` - Mark as read
- `POST /api/notifications/mark-all-read` - Mark all read
- Filter by read/unread status
- Automatic push to connected clients
#### Velociraptor Integration
- Complete Velociraptor API client (async with httpx)
- **Configuration**: `POST /api/velociraptor/config`
- **Client Management**:
- `GET /api/velociraptor/clients` - List clients
- `GET /api/velociraptor/clients/{id}` - Get client info
- **Artifact Collection**:
- `POST /api/velociraptor/collect` - Collect artifact from client
- **Hunt Management**:
- `POST /api/velociraptor/hunts` - Create hunt
- `GET /api/velociraptor/hunts/{id}/results` - Get hunt results
- Per-tenant configuration storage
### Database Changes
- `notifications` table
## Phase 4: Intelligence & Automation ✅
### Implemented Features
#### Machine Learning & Threat Intelligence
- `ThreatAnalyzer` class for ML-based threat detection
- Host threat analysis with scoring (0.0-1.0)
- Artifact threat analysis
- Anomaly detection capabilities
- Threat classification (benign, low, medium, high, critical)
- **Endpoints**:
- `POST /api/threat-intel/analyze/host/{id}` - Analyze host
- `POST /api/threat-intel/analyze/artifact/{id}` - Analyze artifact
- `GET /api/threat-intel/scores` - List threat scores (filterable)
- Stores results in database with confidence scores and indicators
#### Automated Playbooks
- `PlaybookEngine` for executing automated responses
- Supported actions:
- `send_notification` - Send notification to user
- `create_case` - Auto-create investigation case
- `isolate_host` - Isolate compromised host
- `collect_artifact` - Trigger artifact collection
- `block_ip` - Block malicious IP
- `send_email` - Send email alert
- **Endpoints**:
- `GET /api/playbooks` - List playbooks
- `POST /api/playbooks` - Create playbook (admin)
- `GET /api/playbooks/{id}` - Get playbook
- `POST /api/playbooks/{id}/execute` - Execute playbook
- `GET /api/playbooks/{id}/executions` - List execution history
- Trigger types: manual, scheduled, event-based
- Execution tracking with status and results
#### Advanced Reporting
- Report template system
- Multiple format support (PDF, HTML, JSON)
- **Endpoints**:
- `GET /api/reports/templates` - List templates
- `POST /api/reports/templates` - Create template
- `POST /api/reports/generate` - Generate report
- `GET /api/reports` - List generated reports
- `GET /api/reports/{id}` - Get specific report
- Template types: case_summary, host_analysis, threat_report
- Async report generation with status tracking
#### SIEM Integration (Foundation)
- Architecture ready for SIEM connectors
- Audit logs can be forwarded to SIEM
- Threat scores exportable to SIEM
- Webhook/API structure supports integration
- Ready for Splunk, Elastic, etc. connectors
### Database Changes
- `playbooks` table
- `playbook_executions` table
- `threat_scores` table
- `report_templates` table
- `reports` table
## API Statistics
### Total Endpoints: 70+
**By Category:**
- Authentication & Users: 13 endpoints
- Core Resources: 12 endpoints
- Integrations: 15 endpoints
- Intelligence & Automation: 20+ endpoints
- Health & Info: 2 endpoints
### Authentication Required
All endpoints except:
- `POST /api/auth/register`
- `POST /api/auth/login`
- `POST /api/auth/password-reset/request`
- `GET /health`
- `GET /`
### Admin-Only Endpoints
- User management (`/api/users`)
- Tenant creation
- Audit log viewing
- Playbook creation
- Velociraptor hunt creation
## Security Features
### Enhanced Security
- ✅ TOTP 2FA implementation
- ✅ Refresh token rotation
- ✅ Password reset with secure tokens
- ✅ Comprehensive audit logging
- ✅ IP and user agent tracking
- ✅ WebSocket authentication
- ✅ Multi-tenant isolation (all phases)
- ✅ Role-based access control (all endpoints)
### CodeQL Verification
- All phases passed CodeQL security scan
- 0 vulnerabilities detected
- Best practices followed
## Database Schema
### Total Tables: 15
**Phase 1 (5 tables)**
- tenants, users, hosts, cases, artifacts
**Phase 2 (3 tables)**
- refresh_tokens, password_reset_tokens, audit_logs
**Phase 3 (1 table)**
- notifications
**Phase 4 (6 tables)**
- playbooks, playbook_executions, threat_scores, report_templates, reports
### Migrations
All 4 migrations created and tested:
1. `f82b3092d056_initial_migration.py`
2. `a1b2c3d4e5f6_add_phase_2_tables.py`
3. `b2c3d4e5f6g7_add_phase_3_tables.py`
4. `c3d4e5f6g7h8_add_phase_4_tables.py`
## Dependencies Added
```
pyotp==2.9.0 # TOTP 2FA
qrcode[pil]==7.4.2 # QR code generation
websockets==12.0 # WebSocket support
httpx==0.26.0 # Async HTTP client
email-validator==2.1.0 # Email validation
```
## Usage Examples
### Phase 2: 2FA Setup
```python
# 1. Setup 2FA
POST /api/auth/2fa/setup
Response: {"secret": "...", "qr_code_uri": "otpauth://..."}
# 2. Verify and enable
POST /api/auth/2fa/verify
Body: {"code": "123456"}
# 3. Login with 2FA
POST /api/auth/login
Form: username=user&password=pass&scope=123456
```
### Phase 3: Real-time Notifications
```javascript
// Frontend WebSocket connection
const ws = new WebSocket('ws://localhost:8000/api/notifications/ws');
ws.send(JSON.stringify({token: 'jwt_token_here'}));
ws.onmessage = (event) => {
const notification = JSON.parse(event.data);
// Display notification
};
```
### Phase 3: Velociraptor Integration
```python
# Configure Velociraptor
POST /api/velociraptor/config
Body: {"base_url": "https://veloci.example.com", "api_key": "..."}
# Collect artifact
POST /api/velociraptor/collect
Body: {
"client_id": "C.abc123",
"artifact_name": "Windows.System.Pslist"
}
```
### Phase 4: Threat Analysis
```python
# Analyze a host
POST /api/threat-intel/analyze/host/123
Response: {
"score": 0.7,
"confidence": 0.8,
"threat_type": "high",
"indicators": [...]
}
```
### Phase 4: Automated Playbook
```python
# Create playbook
POST /api/playbooks
Body: {
"name": "Isolate High-Risk Host",
"trigger_type": "event",
"actions": [
{"type": "send_notification", "params": {"message": "High risk detected"}},
{"type": "isolate_host", "params": {"host_id": "${host_id}"}},
{"type": "create_case", "params": {"title": "Auto-generated case"}}
]
}
# Execute playbook
POST /api/playbooks/1/execute
```
## Testing
### Manual Testing
All endpoints have been tested with:
- Authentication flows
- Multi-tenancy isolation
- Role-based access control
- Error handling
### API Documentation
Interactive API docs available at:
- Swagger UI: `http://localhost:8000/docs`
- ReDoc: `http://localhost:8000/redoc`
## Deployment Notes
### Environment Variables
Add to `.env`:
```bash
# Phase 2
REFRESH_TOKEN_EXPIRE_DAYS=30
SMTP_HOST=localhost
SMTP_PORT=587
SMTP_USER=
SMTP_PASSWORD=
FROM_EMAIL=noreply@velocicompanion.com
# Phase 3
WS_ENABLED=true
```
### Database Migrations
```bash
# Run all migrations
cd backend
alembic upgrade head
# Or manually in order
alembic upgrade f82b3092d056 # Phase 1
alembic upgrade a1b2c3d4e5f6 # Phase 2
alembic upgrade b2c3d4e5f6g7 # Phase 3
alembic upgrade c3d4e5f6g7h8 # Phase 4
```
## What's Next
The system is now feature-complete with all requested phases implemented:
**Phase 1**: Core Infrastructure & Auth
**Phase 2**: Enhanced Authentication
**Phase 3**: Advanced Features
**Phase 4**: Intelligence & Automation
**Version: 1.0.0 - Production Ready**
### Future Enhancements (Optional)
- Email service integration for password reset
- Advanced ML models for threat detection
- Additional SIEM connectors (Splunk, Elastic, etc.)
- Mobile app for notifications
- Advanced playbook conditions and branching
- Scheduled playbook triggers
- Custom dashboard widgets
- Export/import for playbooks and reports
- Multi-language support
## Support
For issues or questions:
- Check API documentation at `/docs`
- Review ARCHITECTURE.md for technical details
- See QUICKSTART.md for setup instructions
- Consult DEPLOYMENT_CHECKLIST.md for production deployment

View File

@@ -1,263 +0,0 @@
# Quick Start Guide
This guide will help you get VelociCompanion up and running in minutes.
## Prerequisites
- Docker and Docker Compose installed
- 8GB RAM minimum
- Ports 3000, 5432, and 8000 available
## Step 1: Start the Application
```bash
# Clone the repository
git clone https://github.com/mblanke/ThreatHunt.git
cd ThreatHunt
# Start all services
docker-compose up -d
# Check service status
docker-compose ps
```
Expected output:
```
NAME COMMAND SERVICE STATUS PORTS
threathunt-backend-1 "sh -c 'alembic upgr…" backend running 0.0.0.0:8000->8000/tcp
threathunt-db-1 "docker-entrypoint.s…" db running 0.0.0.0:5432->5432/tcp
threathunt-frontend-1 "docker-entrypoint.s…" frontend running 0.0.0.0:3000->3000/tcp
```
## Step 2: Verify Backend is Running
```bash
# Check backend health
curl http://localhost:8000/health
# Expected response:
# {"status":"healthy"}
# View API documentation
open http://localhost:8000/docs
```
## Step 3: Access the Frontend
Open your browser and navigate to:
```
http://localhost:3000
```
You should see the VelociCompanion login page.
## Step 4: Create Your First User
### Option A: Via API (using curl)
```bash
# Register a new user
curl -X POST http://localhost:8000/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"username": "admin",
"password": "admin123",
"role": "admin"
}'
# Login to get a token
curl -X POST http://localhost:8000/api/auth/login \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=admin&password=admin123"
```
### Option B: Via Frontend
1. The first time you access the app, you'll need to register via API first (as shown above)
2. Then login through the frontend at http://localhost:3000/login
## Step 5: Explore the API
Use the interactive API documentation at:
```
http://localhost:8000/docs
```
Click "Authorize" and enter your token in the format:
```
Bearer YOUR_TOKEN_HERE
```
## Step 6: Test the API
Run the test script to verify all endpoints:
```bash
./test_api.sh
```
Expected output:
```
===================================
VelociCompanion API Test Script
===================================
1. Testing health endpoint...
✓ Health check passed
2. Registering a new user...
✓ User registration successful
3. Logging in...
✓ Login successful
4. Getting current user profile...
✓ Profile retrieval successful
5. Listing tenants...
✓ Tenants list retrieved
6. Listing hosts...
Hosts: []
7. Testing authentication protection...
✓ Authentication protection working
===================================
API Testing Complete!
===================================
```
## Common Operations
### Create a Host
```bash
# Get your token from login
TOKEN="your_token_here"
# Create a host
curl -X POST http://localhost:8000/api/hosts \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"hostname": "workstation-01",
"ip_address": "192.168.1.100",
"os": "Windows 10"
}'
```
### List Hosts
```bash
curl -X GET http://localhost:8000/api/hosts \
-H "Authorization: Bearer $TOKEN"
```
### Ingest Data
```bash
curl -X POST http://localhost:8000/api/ingestion/ingest \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"hostname": "server-01",
"data": {
"artifact": "Windows.System.TaskScheduler",
"results": [...]
}
}'
```
## Troubleshooting
### Database Connection Issues
```bash
# Check if database is running
docker-compose logs db
# Restart database
docker-compose restart db
```
### Backend Not Starting
```bash
# Check backend logs
docker-compose logs backend
# Common issues:
# - Database not ready: Wait a few seconds and check logs
# - Port 8000 in use: Stop other services using that port
```
### Frontend Not Loading
```bash
# Check frontend logs
docker-compose logs frontend
# Rebuild frontend if needed
docker-compose build frontend
docker-compose up -d frontend
```
### Reset Everything
```bash
# Stop and remove all containers and volumes
docker-compose down -v
# Start fresh
docker-compose up -d
```
## Next Steps
1. **Create Additional Users**: Use the `/api/auth/register` endpoint
2. **Set Up Tenants**: Create tenants via `/api/tenants` (admin only)
3. **Integrate with Velociraptor**: Configure Velociraptor to send data to `/api/ingestion/ingest`
4. **Explore Cases**: Create and manage threat hunting cases
5. **Configure VirusTotal**: Set up VirusTotal API integration for hash lookups
## Security Considerations
⚠️ **Before deploying to production:**
1. Change the `SECRET_KEY` in docker-compose.yml or .env file
- Must be at least 32 characters
- Use a cryptographically random string
2. Use strong passwords for the database
3. Enable HTTPS/TLS for API and frontend
4. Configure proper firewall rules
5. Review and update CORS settings in `backend/app/main.py`
## Development Mode
To run in development mode with hot reload:
```bash
# Backend
cd backend
python -m venv venv
source venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload
# Frontend (in another terminal)
cd frontend
npm install
npm start
```
## Support
- Documentation: See [README.md](README.md)
- API Docs: http://localhost:8000/docs
- Issues: GitHub Issues

348
README.md
View File

@@ -1,55 +1,334 @@
# VelociCompanion # ThreatHunt - Analyst-Assist Threat Hunting Platform
A multi-tenant threat hunting companion for Velociraptor with JWT authentication and role-based access control. A modern threat hunting platform with integrated analyst-assist agent guidance. Analyze CSV artifact data exported from Velociraptor with AI-powered suggestions for investigation directions, analytical pivots, and hypothesis formation.
## Overview
ThreatHunt is a web application designed to help security analysts efficiently hunt for threats by:
- Importing CSV artifacts from Velociraptor or other sources
- Displaying data in an organized, queryable interface
- Providing AI-powered guidance through an analyst-assist agent
- Suggesting analytical directions, filters, and pivots
- Highlighting anomalies and patterns of interest
> **Agent Policy**: The analyst-assist agent provides read-only guidance only. It does not execute actions, escalate alerts, or modify data. All decisions remain with the analyst.
## Quick Start
### Docker (Recommended)
```bash
# Clone and navigate
git clone https://github.com/mblanke/ThreatHunt.git
cd ThreatHunt
# Configure provider (choose one)
cp .env.example .env
# Edit .env and set your LLM provider:
# Option 1: Online (OpenAI, etc.)
# THREAT_HUNT_AGENT_PROVIDER=online
# THREAT_HUNT_ONLINE_API_KEY=sk-your-key
# Option 2: Local (Ollama, GGML, etc.)
# THREAT_HUNT_AGENT_PROVIDER=local
# THREAT_HUNT_LOCAL_MODEL_PATH=/path/to/model
# Option 3: Networked (Internal inference service)
# THREAT_HUNT_AGENT_PROVIDER=networked
# THREAT_HUNT_NETWORKED_ENDPOINT=http://service:5000
# Start services
docker-compose up -d
# Verify
curl http://localhost:8000/api/agent/health
curl http://localhost:3000
```
Access at http://localhost:3000
### Local Development
**Backend**:
```bash
cd backend
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
# Configure provider
export THREAT_HUNT_ONLINE_API_KEY=sk-your-key
# OR set another provider env var
# Run
python run.py
# API at http://localhost:8000/docs
```
**Frontend** (new terminal):
```bash
cd frontend
npm install
npm start
# App at http://localhost:3000
```
## Features ## Features
- **JWT Authentication**: Secure token-based authentication system ### Analyst-Assist Agent 🤖
- **Multi-Tenancy**: Complete data isolation between tenants - **Read-only guidance**: Explains data patterns and suggests investigation directions
- **Role-Based Access Control**: Admin and user roles with different permissions - **Context-aware**: Understands current dataset, host, and artifact type
- **RESTful API**: FastAPI backend with automatic OpenAPI documentation - **Pluggable providers**: Local, networked, or online LLM backends
- **React Frontend**: Modern TypeScript React application with authentication - **Transparent reasoning**: Explains logic with caveats and confidence scores
- **Database Migrations**: Alembic for database schema management - **Governance-compliant**: Strictly adheres to agent policy (no execution, no escalation)
- **Docker Support**: Complete Docker Compose setup for easy deployment
### Chat Interface
- Analyst asks questions about artifact data
- Agent provides guidance with suggested pivots and filters
- Conversation history for context continuity
- Real-time typing and response indicators
### Data Management
- Import CSV artifacts from Velociraptor
- Browse and filter findings by severity, host, artifact type
- Annotate findings with analyst notes
- Track investigation progress
## Architecture
### Backend
- **Framework**: FastAPI (Python 3.11)
- **Agent Module**: Pluggable LLM provider interface
- **API**: RESTful endpoints with OpenAPI documentation
- **Structure**: Modular design with clear separation of concerns
### Frontend
- **Framework**: React 18 with TypeScript
- **Components**: Agent chat panel + analysis dashboard
- **Styling**: CSS with responsive design
- **State Management**: React hooks + Context API
### LLM Providers
Supports three provider architectures:
1. **Local**: On-device or on-prem models (GGML, Ollama, vLLM)
2. **Networked**: Shared internal inference services
3. **Online**: External hosted APIs (OpenAI, Anthropic, Google)
Auto-detection: Automatically uses the first available provider.
## Project Structure ## Project Structure
``` ```
ThreatHunt/ ThreatHunt/
├── backend/ ├── backend/
│ ├── alembic/ # Database migrations
│ ├── app/ │ ├── app/
│ │ ├── api/routes/ # API endpoints │ │ ├── agents/ # Analyst-assist agent
│ │ │ ├── auth.py # Authentication routes │ │ │ ├── core.py # ThreatHuntAgent class
│ │ │ ├── users.py # User management │ │ │ ├── providers.py # LLM provider interface
│ │ │ ├── tenants.py # Tenant management │ │ │ ├── config.py # Configuration
│ │ │ ── hosts.py # Host management │ │ │ ── __init__.py
│ │ │ ├── ingestion.py # Data ingestion │ │ ├── api/routes/ # API endpoints
│ │ │ ── vt.py # VirusTotal integration │ │ │ ── agent.py # /api/agent/* routes
│ │ ├── core/ # Core functionality │ │ │ ├── __init__.py
│ │ │ ├── config.py # Configuration │ │ ├── main.py # FastAPI app
│ │ │ ├── database.py # Database setup │ │ └── __init__.py
│ │ │ ├── security.py # Password hashing, JWT
│ │ │ └── deps.py # FastAPI dependencies
│ │ ├── models/ # SQLAlchemy models
│ │ └── schemas/ # Pydantic schemas
│ ├── requirements.txt │ ├── requirements.txt
│ ├── run.py
│ └── Dockerfile │ └── Dockerfile
├── frontend/ ├── frontend/
│ ├── public/
│ ├── src/ │ ├── src/
│ │ ├── components/ # React components │ │ ├── components/
│ │ ├── context/ # Auth context │ │ │ ├── AgentPanel.tsx # Chat interface
│ │ ├── pages/ # Page components │ │ │ └── AgentPanel.css
│ │ ├── utils/ # API utilities │ │ ├── utils/
│ │ │ └── agentApi.ts # API communication
│ │ ├── App.tsx │ │ ├── App.tsx
│ │ ── index.tsx │ │ ── App.css
│ │ ├── index.tsx
│ │ └── index.css
│ ├── public/index.html
│ ├── package.json │ ├── package.json
│ ├── tsconfig.json
│ └── Dockerfile │ └── Dockerfile
── docker-compose.yml ── docker-compose.yml
├── .env.example
├── .gitignore
├── AGENT_IMPLEMENTATION.md # Technical guide
├── INTEGRATION_GUIDE.md # Deployment guide
├── IMPLEMENTATION_SUMMARY.md # Overview
├── README.md # This file
├── ROADMAP.md
└── THREATHUNT_INTENT.md
``` ```
## API Endpoints
### Agent Assistance
- **POST /api/agent/assist** - Request guidance on artifact data
- **GET /api/agent/health** - Check agent availability
See full API documentation at http://localhost:8000/docs
## Configuration
### LLM Provider Selection
Set via `THREAT_HUNT_AGENT_PROVIDER` environment variable:
```bash
# Auto-detect (tries local → networked → online)
THREAT_HUNT_AGENT_PROVIDER=auto
# Local (on-device/on-prem)
THREAT_HUNT_AGENT_PROVIDER=local
THREAT_HUNT_LOCAL_MODEL_PATH=/models/model.gguf
# Networked (internal service)
THREAT_HUNT_AGENT_PROVIDER=networked
THREAT_HUNT_NETWORKED_ENDPOINT=http://inference:5000
THREAT_HUNT_NETWORKED_KEY=api-key
# Online (hosted API)
THREAT_HUNT_AGENT_PROVIDER=online
THREAT_HUNT_ONLINE_API_KEY=sk-your-key
THREAT_HUNT_ONLINE_PROVIDER=openai
THREAT_HUNT_ONLINE_MODEL=gpt-3.5-turbo
```
### Agent Behavior
```bash
THREAT_HUNT_AGENT_MAX_TOKENS=1024
THREAT_HUNT_AGENT_REASONING=true
THREAT_HUNT_AGENT_HISTORY_LENGTH=10
THREAT_HUNT_AGENT_FILTER_SENSITIVE=true
```
See `.env.example` for all configuration options.
## Governance & Compliance
This implementation strictly follows governance principles:
-**Agents assist analysts** - No autonomous execution
-**No tool execution** - Agent provides guidance only
-**No alert escalation** - Analyst controls alerts
-**No data modification** - Read-only analysis
-**Transparent reasoning** - Explains guidance with caveats
-**Analyst authority** - All decisions remain with analyst
**References**:
- `goose-core/governance/AGENT_POLICY.md`
- `goose-core/governance/AI_RULES.md`
- `THREATHUNT_INTENT.md`
## Documentation
- **[AGENT_IMPLEMENTATION.md](AGENT_IMPLEMENTATION.md)** - Detailed technical architecture
- **[INTEGRATION_GUIDE.md](INTEGRATION_GUIDE.md)** - Deployment and configuration
- **[IMPLEMENTATION_SUMMARY.md](IMPLEMENTATION_SUMMARY.md)** - Feature overview
## Testing the Agent
### Check Health
```bash
curl http://localhost:8000/api/agent/health
```
### Test API
```bash
curl -X POST http://localhost:8000/api/agent/assist \
-H "Content-Type: application/json" \
-d '{
"query": "What patterns suggest suspicious activity?",
"dataset_name": "FileList",
"artifact_type": "FileList",
"host_identifier": "DESKTOP-ABC123"
}'
```
### Use UI
1. Open http://localhost:3000
2. Enter a question in the agent panel
3. View guidance with suggested pivots and filters
## Troubleshooting
### Agent Unavailable (503)
- Check environment variables for provider configuration
- Verify LLM provider is accessible
- See logs: `docker-compose logs backend`
### No Frontend Response
- Verify backend health: `curl http://localhost:8000/api/agent/health`
- Check browser console for errors
- See logs: `docker-compose logs frontend`
See [INTEGRATION_GUIDE.md](INTEGRATION_GUIDE.md) for detailed troubleshooting.
## Development
### Running Tests
```bash
cd backend
pytest
cd ../frontend
npm test
```
### Building Images
```bash
docker-compose build
```
### Logs
```bash
docker-compose logs -f backend
docker-compose logs -f frontend
```
## Security Notes
For production deployment:
1. Add authentication to API endpoints
2. Enable HTTPS/TLS
3. Implement rate limiting
4. Filter sensitive data before LLM
5. Add audit logging
6. Use secrets management for API keys
See [INTEGRATION_GUIDE.md](INTEGRATION_GUIDE.md#security-notes) for details.
## Future Enhancements
- [ ] Integration with actual CVE databases
- [ ] Fine-tuned models for cybersecurity domain
- [ ] Structured output from LLMs (JSON mode)
- [ ] Feedback loop on guidance quality
- [ ] Multi-modal support (images, documents)
- [ ] Compliance reporting and audit trails
- [ ] Performance optimization and caching
## Contributing
Follow the architecture and governance principles in `goose-core`. All changes must:
- Adhere to agent policy (read-only, advisory only)
- Conform to shared terminology in goose-core
- Include appropriate documentation
- Pass tests and lint checks
## License
See LICENSE file
## Support
For issues or questions:
1. Check [INTEGRATION_GUIDE.md](INTEGRATION_GUIDE.md)
2. Review [AGENT_IMPLEMENTATION.md](AGENT_IMPLEMENTATION.md)
3. See API docs at http://localhost:8000/docs
4. Check backend logs for errors
## Getting Started ## Getting Started
### Prerequisites ### Prerequisites
@@ -130,7 +409,7 @@ npm start
- `GET /api/hosts/{host_id}` - Get host by ID - `GET /api/hosts/{host_id}` - Get host by ID
### Ingestion ### Ingestion
- `POST /api/ingestion/ingest` - Ingest data from Velociraptor - `POST /api/ingestion/ingest` - Upload and parse CSV files exported from Velociraptor
### VirusTotal ### VirusTotal
- `POST /api/vt/lookup` - Lookup hash in VirusTotal - `POST /api/vt/lookup` - Lookup hash in VirusTotal
@@ -174,6 +453,7 @@ alembic downgrade -1
- `DATABASE_URL` - PostgreSQL connection string - `DATABASE_URL` - PostgreSQL connection string
- `SECRET_KEY` - Secret key for JWT signing (min 32 characters) - `SECRET_KEY` - Secret key for JWT signing (min 32 characters)
- `ACCESS_TOKEN_EXPIRE_MINUTES` - JWT token expiration time (default: 30) - `ACCESS_TOKEN_EXPIRE_MINUTES` - JWT token expiration time (default: 30)
- `VT_API_KEY` - VirusTotal API key for hash lookups
### Frontend ### Frontend
- `REACT_APP_API_URL` - Backend API URL (default: http://localhost:8000) - `REACT_APP_API_URL` - Backend API URL (default: http://localhost:8000)
@@ -213,4 +493,4 @@ npm test
## Support ## Support
For issues and questions, please open an issue on GitHub. For issues and questions, please open an issue on GitHub.

View File

@@ -0,0 +1,21 @@
# Operating Model
## Default cadence
- Prefer iterative progress over big bangs.
- Keep diffs small: target ≤ 300 changed lines per PR unless justified.
- Update tests/docs as part of the same change when possible.
## Working agreement
- Start with a PLAN for non-trivial tasks.
- Implement the smallest slice that satisfies acceptance criteria.
- Verify via DoD.
- Write a crisp PR summary: what changed, why, and how verified.
## Stop conditions (plan first)
Stop and produce a PLAN (do not code yet) if:
- scope is unclear
- more than 3 files will change
- data model changes
- auth/security boundaries
- performance-critical paths

View File

@@ -0,0 +1,36 @@
# Agent Types & Roles (Practical Taxonomy)
Use this skill to choose the *right* kind of agent workflow for the job.
## Common agent "types" (in practice)
### 1) Chat assistant (no tools)
Best for: explanations, brainstorming, small edits.
Risk: can hallucinate; no grounding in repo state.
### 2) Tool-using single agent
Best for: well-scoped tasks where the agent can read/write files and run commands.
Key control: strict DoD gates + minimal permissions.
### 3) Planner + Executor (2-role pattern)
Best for: medium complexity work (multi-file changes, feature work).
Flow: Planner writes plan + acceptance criteria → Executor implements → Reviewer checks.
### 4) Multi-agent (specialists)
Best for: bigger features with separable workstreams (UI, backend, docs, tests).
Rule: isolate context per role; use separate branches/worktrees.
### 5) Supervisor / orchestrator
Best for: long-running workflows with checkpoints (pipelines, report generation, PAD docs).
Rule: supervisor delegates, enforces gates, and composes final output.
## Decision rules (fast)
- If you can describe it in ≤ 5 steps → single tool-using agent.
- If you need tradeoffs/design → Planner + Executor.
- If UI + backend + docs/tests all move → multi-agent specialists.
- If it's a pipeline that runs repeatedly → orchestrator.
## Guardrails (always)
- DoD is the truth gate.
- Separate branches/worktrees for parallel work.
- Log decisions + commands in AGENT_LOG.md.

View File

@@ -0,0 +1,24 @@
# Definition of Done (DoD)
A change is "done" only when:
## Code correctness
- Builds successfully (if applicable)
- Tests pass
- Linting/formatting passes
- Types/checks pass (if applicable)
## Quality
- No new warnings introduced
- Edge cases handled (inputs validated, errors meaningful)
- Hot paths not regressed (if applicable)
## Hygiene
- No secrets committed
- Docs updated if behavior or usage changed
- PR summary includes verification steps
## Commands
- macOS/Linux: `./scripts/dod.sh`
- Windows: `\scripts\dod.ps1`

16
SKILLS/20-repo-map.md Normal file
View File

@@ -0,0 +1,16 @@
# Repo Mapping Skill
When entering a repo:
1) Read README.md
2) Identify entrypoints (app main / server startup / CLI)
3) Identify config (env vars, .env.example, config files)
4) Identify test/lint scripts (package.json, pyproject.toml, Makefile, etc.)
5) Write a 10-line "repo map" in the PLAN before changing code
Output format:
- Purpose:
- Key modules:
- Data flow:
- Commands:
- Risks:

View File

@@ -0,0 +1,20 @@
# Algorithms & Performance
Use this skill when performance matters (large inputs, hot paths, or repeated calls).
## Checklist
- Identify the **state** you're recomputing.
- Add **memoization / caching** when the same subproblem repeats.
- Prefer **linear scans** + caches over nested loops when possible.
- If you can write it as a **recurrence**, you can test it.
## Practical heuristics
- Measure first when possible (timing + input sizes).
- Optimize the biggest wins: avoid repeated I/O, repeated parsing, repeated network calls.
- Keep caches bounded (size/TTL) and invalidate safely.
- Choose data structures intentionally: dict/set for membership, heap for top-k, deque for queues.
## Review notes (for PRs)
- Call out accidental O(n²) patterns.
- Suggest table/DP or memoization when repeated work is obvious.
- Add tests that cover base cases + typical cases + worst-case size.

View File

@@ -0,0 +1,31 @@
# Vibe Coding With Fundamentals (Safety Rails)
Use this skill when you're using "vibe coding" (fast, conversational building) but want production-grade outcomes.
## The good
- Rapid scaffolding and iteration
- Fast UI prototypes
- Quick exploration of architectures and options
## The failure mode
- "It works on my machine" code with weak tests
- Security foot-guns (auth, input validation, secrets)
- Performance cliffs (accidental O(n²), repeated I/O)
- Unmaintainable abstractions
## Safety rails (apply every time)
- Always start with acceptance criteria (what "done" means).
- Prefer small PRs; never dump a huge AI diff.
- Require DoD gates (lint/test/build) before merge.
- Write tests for behavior changes.
- For anything security/data related: do a Reviewer pass.
## When to slow down
- Auth/session/token work
- Anything touching payments, PII, secrets
- Data migrations/schema changes
- Performance-critical paths
- "It's flaky" or "it only fails in CI"
## Practical prompt pattern (use in PLAN)
- "State assumptions, list files to touch, propose tests, and include rollback steps."

View File

@@ -0,0 +1,31 @@
# Performance Profiling (Bun/Node)
Use this skill when:
- a hot path feels slow
- CPU usage is high
- you suspect accidental O(n²) or repeated work
- you need evidence before optimizing
## Bun CPU profiling
Bun supports CPU profiling via `--cpu-prof` (generates a `.cpuprofile` you can open in Chrome DevTools).
Upcoming: `bun --cpu-prof-md <script>` outputs a CPU profile as **Markdown** so LLMs can read/grep it easily.
### Workflow (Bun)
1) Run the workload with profiling enabled
- Today: `bun --cpu-prof ./path/to/script.ts`
- Upcoming: `bun --cpu-prof-md ./path/to/script.ts`
2) Save the output (or `.cpuprofile`) into `./profiles/` with a timestamp.
3) Ask the Reviewer agent to:
- identify the top 5 hottest functions
- propose the smallest fix
- add a regression test or benchmark
## Node CPU profiling (fallback)
- `node --cpu-prof ./script.js` writes a `.cpuprofile` file.
- Open in Chrome DevTools → Performance → Load profile.
## Rules
- Optimize based on measured hotspots, not vibes.
- Prefer algorithmic wins (remove repeated work) over micro-optimizations.
- Keep profiling artifacts out of git unless explicitly needed (use `.gitignore`).

View File

@@ -0,0 +1,16 @@
# Implementation Rules
## Change policy
- Prefer edits over rewrites.
- Keep changes localized.
- One change = one purpose.
- Avoid unnecessary abstraction.
## Dependency policy
- Default: do not add dependencies.
- If adding: explain why, alternatives considered, and impact.
## Error handling
- Validate inputs at boundaries.
- Error messages must be actionable: what failed + what to do next.

View File

@@ -0,0 +1,14 @@
# Testing & Quality
## Strategy
- If behavior changes: add/update tests.
- Unit tests for logic; integration tests for boundaries; E2E only where needed.
## Minimum for every PR
- A test plan in the PR summary (even if "existing tests cover this").
- Run DoD.
## Flaky tests
- Capture repro steps.
- Quarantine only with justification + follow-up issue.

16
SKILLS/50-pr-review.md Normal file
View File

@@ -0,0 +1,16 @@
# PR Review Skill
Reviewer must check:
- Correctness: does it do what it claims?
- Safety: secrets, injection, auth boundaries
- Maintainability: readability, naming, duplication
- Tests: added/updated appropriately
- DoD: did it pass?
Reviewer output format:
1) Summary
2) Must-fix
3) Nice-to-have
4) Risks
5) Verification suggestions

View File

@@ -0,0 +1,41 @@
# Material UI (MUI) Design System
Use this skill for any React/Next "portal/admin/dashboard" UI so you stay consistent and avoid random component soup.
## Standard choice
- Preferred UI library: **MUI (Material UI)**.
- Prefer MUI components over ad-hoc HTML/CSS unless there's a good reason.
- One design system per repo (do not mix Chakra/Ant/Bootstrap/etc.).
## Setup (Next.js/React)
- Install: `@mui/material @emotion/react @emotion/styled`
- If using icons: `@mui/icons-material`
- If using data grid: `@mui/x-data-grid` (or pro if licensed)
## Theming rules
- Define a single theme (typography, spacing, palette) and reuse everywhere.
- Use semantic colors (primary/secondary/error/warning/success/info), not hard-coded hex everywhere.
- Prefer MUI's `sx` for small styling; use `styled()` for reusable components.
## "Portal" patterns (modals, popovers, menus)
- Use MUI Dialog/Modal/Popover/Menu components instead of DIY portals.
- Accessibility requirements:
- Focus is trapped in Dialog/Modal.
- Escape closes modal unless explicitly prevented.
- All inputs have labels; buttons have clear text/aria-labels.
- Keyboard navigation works end-to-end.
## Layout conventions (for portals)
- Use: AppBar + Drawer (or NavigationRail equivalent) + main content.
- Keep pages as composition of small components: Page → Sections → Widgets.
- Keep forms consistent: FormControl + helper text + validation messages.
## Performance hygiene
- Avoid re-render storms: memoize heavy lists; use virtualization for large tables (DataGrid).
- Prefer server pagination for huge datasets.
## PR review checklist
- Theme is used (no random styling).
- Components are MUI where reasonable.
- Modal/popover accessibility is correct.
- No mixed UI libraries.

View File

@@ -0,0 +1,15 @@
# Security & Safety
## Secrets
- Never output secrets or tokens.
- Never log sensitive inputs.
- Never commit credentials.
## Inputs
- Validate external inputs at boundaries.
- Fail closed for auth/security decisions.
## Tooling
- No destructive commands unless requested and scoped.
- Prefer read-only operations first.

View File

@@ -0,0 +1,13 @@
# Docs & Artifacts
Update documentation when:
- setup steps change
- env vars change
- endpoints/CLI behavior changes
- data formats change
Docs standards:
- Provide copy/paste commands
- Provide expected outputs where helpful
- Keep it short and accurate

11
SKILLS/80-mcp-tools.md Normal file
View File

@@ -0,0 +1,11 @@
# MCP Tools Skill (Optional)
If this repo defines MCP servers/tools:
Rules:
- Tool calls must be explicit and logged.
- Maintain an allowlist of tools; deny by default.
- Every tool must have: purpose, inputs/outputs schema, examples, and tests.
- Prefer idempotent tool operations.
- Never add tools that can exfiltrate secrets without strict guards.

View File

@@ -0,0 +1,51 @@
# MCP Server Design (Agent-First)
Build MCP servers like you're designing a UI for a non-human user.
This skill distills Phil Schmid's MCP server best practices into concrete repo rules.
Source: "MCP is Not the Problem, It's your Server" (Jan 21, 2026).
## 1) Outcomes, not operations
- Do **not** wrap REST endpoints 1:1 as tools.
- Expose high-level, outcome-oriented tools.
- Bad: `get_user`, `list_orders`, `get_order_status`
- Good: `track_latest_order(email)` (server orchestrates internally)
## 2) Flatten arguments
- Prefer top-level primitives + constrained enums.
- Avoid nested `dict`/config objects (agents hallucinate keys).
- Defaults reduce decision load.
## 3) Instructions are context
- Tool docstrings are *instructions*:
- when to use the tool
- argument formatting rules
- what the return means
- Error strings are also context:
- return actionable, self-correcting messages (not raw stack traces)
## 4) Curate ruthlessly
- Aim for **515 tools** per server.
- One server, one job. Split by persona if needed.
- Delete unused tools. Don't dump raw data into context.
## 5) Name tools for discovery
- Avoid generic names (`create_issue`).
- Prefer `{service}_{action}_{resource}`:
- `velociraptor_run_hunt`
- `github_list_prs`
- `slack_send_message`
## 6) Paginate large results
- Always support `limit` (default ~2050).
- Return metadata: `has_more`, `next_offset`, `total_count`.
- Never return hundreds of rows unbounded.
## Repo conventions
- Put MCP tool specs in `mcp/` (schemas, examples, fixtures).
- Provide at least 1 "golden path" example call per tool.
- Add an eval that checks:
- tool names follow discovery convention
- args are flat + typed
- responses are concise + stable
- pagination works

View File

@@ -0,0 +1,40 @@
# FastMCP 3 Patterns (Providers + Transforms)
Use this skill when you are building MCP servers in Python and want:
- composable tool sets
- per-user/per-session behavior
- auth, versioning, observability, and long-running tasks
## Mental model (FastMCP 3)
FastMCP 3 treats everything as three composable primitives:
- **Components**: what you expose (tools, resources, prompts)
- **Providers**: where components come from (decorators, files, OpenAPI, remote MCP, etc.)
- **Transforms**: how you reshape what clients see (namespace, filters, auth, versioning, visibility)
## Recommended architecture for Marc's platform
Build a **single "Cyber MCP Gateway"** that composes providers:
- LocalProvider: core cyber tools (run hunt, parse triage, generate report)
- OpenAPIProvider: wrap stable internal APIs (ticketing, asset DB) without 1:1 endpoint exposure
- ProxyProvider/FastMCPProvider: mount sub-servers (e.g., Velociraptor tools, Intel feeds)
Then apply transforms:
- Namespace per domain: `hunt.*`, `intel.*`, `pad.*`
- Visibility per session: hide dangerous tools unless user/role allows
- VersionFilter: keep old clients working while you evolve tools
## Production must-haves
- **Tool timeouts**: never let a tool hang forever
- **Pagination**: all list tools must be bounded
- **Background tasks**: use for long hunts / ingest jobs
- **Tracing**: emit OpenTelemetry traces so you can debug agent/tool behavior
## Auth rules
- Prefer component-level auth for "dangerous" tools.
- Default stance: read-only tools visible; write/execute tools gated.
## Versioning rules
- Version your components when you change schemas or semantics.
- Keep 1 previous version callable during migrations.
## Upgrade guidance
FastMCP 3 is in beta; pin to v2 for stability in production until you've tested.

View File

@@ -1,232 +0,0 @@
# Validation Report
**Date**: 2025-12-09
**Version**: 1.0.0
**Status**: ✅ ALL CHECKS PASSED
## Summary
Comprehensive error checking and validation has been performed on all components of the VelociCompanion threat hunting platform.
## Python Backend Validation
### ✅ Syntax Check
- All Python files compile successfully
- No syntax errors found in 53 files
### ✅ Import Validation
- All core modules import correctly
- All 12 model classes verified
- All schema modules working
- All 12 route modules operational
- All engine modules (Velociraptor, ThreatAnalyzer, PlaybookEngine) functional
### ✅ FastAPI Application
- Application loads successfully
- 53 routes registered correctly
- Version 1.0.0 confirmed
- All route tags properly assigned
### ✅ API Endpoints Registered
**Authentication** (10 endpoints)
- POST /api/auth/register
- POST /api/auth/login
- POST /api/auth/refresh
- GET /api/auth/me
- PUT /api/auth/me
- POST /api/auth/2fa/setup
- POST /api/auth/2fa/verify
- POST /api/auth/2fa/disable
- POST /api/auth/password-reset/request
- POST /api/auth/password-reset/confirm
**Users** (4 endpoints)
- GET /api/users/
- GET /api/users/{user_id}
- PUT /api/users/{user_id}
- DELETE /api/users/{user_id}
**Tenants** (3 endpoints)
- GET /api/tenants/
- POST /api/tenants/
- GET /api/tenants/{tenant_id}
**Hosts** (3 endpoints)
- GET /api/hosts/
- POST /api/hosts/
- GET /api/hosts/{host_id}
**Audit Logs** (2 endpoints)
- GET /api/audit/
- GET /api/audit/{log_id}
**Notifications** (3 endpoints)
- GET /api/notifications/
- PUT /api/notifications/{notification_id}
- POST /api/notifications/mark-all-read
**Velociraptor** (6 endpoints)
- POST /api/velociraptor/config
- GET /api/velociraptor/clients
- GET /api/velociraptor/clients/{client_id}
- POST /api/velociraptor/collect
- POST /api/velociraptor/hunts
- GET /api/velociraptor/hunts/{hunt_id}/results
**Playbooks** (5 endpoints)
- GET /api/playbooks/
- POST /api/playbooks/
- GET /api/playbooks/{playbook_id}
- POST /api/playbooks/{playbook_id}/execute
- GET /api/playbooks/{playbook_id}/executions
**Threat Intelligence** (3 endpoints)
- POST /api/threat-intel/analyze/host/{host_id}
- POST /api/threat-intel/analyze/artifact/{artifact_id}
- GET /api/threat-intel/scores
**Reports** (5 endpoints)
- GET /api/reports/templates
- POST /api/reports/templates
- POST /api/reports/generate
- GET /api/reports/
- GET /api/reports/{report_id}
**Other** (4 endpoints)
- POST /api/ingestion/ingest
- POST /api/vt/lookup
- GET /
- GET /health
**Total**: 53 routes successfully registered
## Frontend Validation
### ✅ TypeScript Files
- All 8 TypeScript/TSX files validated
- Import statements correct
- Component hierarchy verified
### ✅ File Structure
```
src/
├── App.tsx ✓
├── index.tsx ✓
├── react-app-env.d.ts ✓
├── components/
│ └── PrivateRoute.tsx ✓
├── context/
│ └── AuthContext.tsx ✓
├── pages/
│ ├── Login.tsx ✓
│ └── Dashboard.tsx ✓
└── utils/
└── api.ts ✓
```
### ✅ Configuration Files
- package.json: Valid JSON ✓
- tsconfig.json: Present ✓
- Dockerfile: Present ✓
## Database Validation
### ✅ Migration Chain
Correct migration dependency chain:
1. f82b3092d056 (Phase 1 - Initial) → None
2. a1b2c3d4e5f6 (Phase 2) → f82b3092d056
3. b2c3d4e5f6g7 (Phase 3) → a1b2c3d4e5f6
4. c3d4e5f6g7h8 (Phase 4) → b2c3d4e5f6g7
### ✅ Database Models
All 15 tables defined:
- Phase 1: tenants, users, hosts, cases, artifacts
- Phase 2: refresh_tokens, password_reset_tokens, audit_logs
- Phase 3: notifications
- Phase 4: playbooks, playbook_executions, threat_scores, report_templates, reports
## Infrastructure Validation
### ✅ Docker Compose
- PostgreSQL service configured ✓
- Backend service with migrations ✓
- Frontend service configured ✓
- Health checks enabled ✓
- Volume mounts correct ✓
### ✅ Configuration Files
- alembic.ini: Valid ✓
- requirements.txt: Valid (email-validator updated to 2.1.2) ✓
- .env.example: Present ✓
## Documentation Validation
### ✅ Documentation Files Present
- README.md ✓
- QUICKSTART.md ✓
- ARCHITECTURE.md ✓
- DEPLOYMENT_CHECKLIST.md ✓
- IMPLEMENTATION_SUMMARY.md ✓
- PHASES_COMPLETE.md ✓
### ✅ Internal Links
- All markdown cross-references validated
- File references correct
### ✅ Scripts
- test_api.sh: Valid bash syntax ✓
## Dependencies
### ✅ Python Dependencies
All required packages specified:
- fastapi==0.109.0
- uvicorn[standard]==0.27.0
- sqlalchemy==2.0.25
- psycopg2-binary==2.9.9
- python-jose[cryptography]==3.3.0
- passlib[bcrypt]==1.7.4
- python-multipart==0.0.6
- alembic==1.13.1
- pydantic==2.5.3
- pydantic-settings==2.1.0
- pyotp==2.9.0
- qrcode[pil]==7.4.2
- websockets==12.0
- httpx==0.26.0
- email-validator==2.1.2 (updated from 2.1.0)
### ✅ Node Dependencies
- React 18.2.0
- TypeScript 5.3.3
- React Router 6.21.0
- Axios 1.6.2
## Security
### ✅ Security Checks
- No hardcoded credentials in code
- Environment variables used for secrets
- JWT tokens properly secured
- Password hashing with bcrypt
- 0 vulnerabilities reported by CodeQL
## Issues Fixed
1. **email-validator version**: Updated from 2.1.0 to 2.1.2 to avoid yanked version warning
## Conclusion
**All validation checks passed successfully**
The VelociCompanion platform is fully functional with:
- 53 API endpoints operational
- 15 database tables with correct relationships
- 4 complete migration files
- All imports and dependencies resolved
- Frontend components properly structured
- Docker infrastructure configured
- Comprehensive documentation
**Status**: Production Ready
**Recommended Action**: Deploy to staging for integration testing

View File

@@ -1,3 +0,0 @@
DATABASE_URL=postgresql://postgres:postgres@db:5432/velocicompanion
SECRET_KEY=your-secret-key-change-in-production-min-32-chars-long
ACCESS_TOKEN_EXPIRE_MINUTES=30

View File

@@ -1,13 +0,0 @@
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Run migrations and start server
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"]

View File

@@ -12,6 +12,8 @@ script_location = %(here)s/alembic
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file # see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens # for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present. # sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator # defaults to the current working directory. for multiple paths, the path separator
@@ -84,7 +86,7 @@ path_separator = os
# database URL. This is consumed by the user-maintained env.py script only. # database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py # other means of configuring database URLs may be customized within the env.py
# file. # file.
# sqlalchemy.url is configured in env.py from app.core.config sqlalchemy.url = sqlite+aiosqlite:///./threathunt.db
[post_write_hooks] [post_write_hooks]

View File

@@ -1,92 +1,64 @@
from logging.config import fileConfig """Alembic async env — autogenerate from app.db.models."""
import sys
from pathlib import Path import asyncio
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context from alembic import context
# Add app directory to Python path # Alembic Config
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
# Import models and database
from app.core.database import Base
from app.core.config import settings
# Import all models to ensure they're registered with Base
from app.models.tenant import Tenant
from app.models.user import User
from app.models.host import Host
from app.models.case import Case
from app.models.artifact import Artifact
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config config = context.config
# Set the database URL from settings
config.set_main_option("sqlalchemy.url", settings.database_url)
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None: if config.config_file_name is not None:
fileConfig(config.config_file_name) fileConfig(config.config_file_name)
# add your model's MetaData object here # Import all models so autogenerate sees them
# for 'autogenerate' support from app.db.engine import Base # noqa: E402
target_metadata = Base.metadata from app.db import models as _models # noqa: E402, F401
# other values from the config, defined by the needs of env.py, target_metadata = Base.metadata
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None: def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode. """Run migrations in 'offline' mode."""
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url") url = config.get_main_option("sqlalchemy.url")
context.configure( context.configure(
url=url, url=url,
target_metadata=target_metadata, target_metadata=target_metadata,
literal_binds=True, literal_binds=True,
dialect_opts={"paramstyle": "named"}, dialect_opts={"paramstyle": "named"},
render_as_batch=True, # required for SQLite ALTER TABLE
) )
with context.begin_transaction(): with context.begin_transaction():
context.run_migrations() context.run_migrations()
def run_migrations_online() -> None: def do_run_migrations(connection):
"""Run migrations in 'online' mode. context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
In this scenario we need to create an Engine
and associate a connection with the context.
""" async def run_async_migrations() -> None:
connectable = engine_from_config( """Run migrations in 'online' mode with an async engine."""
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}), config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.", prefix="sqlalchemy.",
poolclass=pool.NullPool, poolclass=pool.NullPool,
) )
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction(): def run_migrations_online() -> None:
context.run_migrations() asyncio.run(run_async_migrations())
if context.is_offline_mode(): if context.is_offline_mode():

View File

@@ -0,0 +1,210 @@
"""initial schema
Revision ID: 9790f482da06
Revises:
Create Date: 2026-02-19 11:40:02.108830
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '9790f482da06'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('username', sa.String(length=64), nullable=False),
sa.Column('email', sa.String(length=256), nullable=False),
sa.Column('hashed_password', sa.String(length=256), nullable=False),
sa.Column('role', sa.String(length=16), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_users_username'), ['username'], unique=True)
op.create_table('hunts',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('name', sa.String(length=256), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('status', sa.String(length=32), nullable=False),
sa.Column('owner_id', sa.String(length=32), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['owner_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('datasets',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('name', sa.String(length=256), nullable=False),
sa.Column('filename', sa.String(length=512), nullable=False),
sa.Column('source_tool', sa.String(length=64), nullable=True),
sa.Column('row_count', sa.Integer(), nullable=False),
sa.Column('column_schema', sa.JSON(), nullable=True),
sa.Column('normalized_columns', sa.JSON(), nullable=True),
sa.Column('ioc_columns', sa.JSON(), nullable=True),
sa.Column('file_size_bytes', sa.Integer(), nullable=False),
sa.Column('encoding', sa.String(length=32), nullable=True),
sa.Column('delimiter', sa.String(length=4), nullable=True),
sa.Column('time_range_start', sa.DateTime(timezone=True), nullable=True),
sa.Column('time_range_end', sa.DateTime(timezone=True), nullable=True),
sa.Column('hunt_id', sa.String(length=32), nullable=True),
sa.Column('uploaded_by', sa.String(length=32), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['hunt_id'], ['hunts.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('datasets', schema=None) as batch_op:
batch_op.create_index('ix_datasets_hunt', ['hunt_id'], unique=False)
batch_op.create_index(batch_op.f('ix_datasets_name'), ['name'], unique=False)
op.create_table('hypotheses',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('hunt_id', sa.String(length=32), nullable=True),
sa.Column('title', sa.String(length=256), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('mitre_technique', sa.String(length=32), nullable=True),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('evidence_row_ids', sa.JSON(), nullable=True),
sa.Column('evidence_notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['hunt_id'], ['hunts.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('hypotheses', schema=None) as batch_op:
batch_op.create_index('ix_hypotheses_hunt', ['hunt_id'], unique=False)
op.create_table('conversations',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('title', sa.String(length=256), nullable=True),
sa.Column('hunt_id', sa.String(length=32), nullable=True),
sa.Column('dataset_id', sa.String(length=32), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['dataset_id'], ['datasets.id'], ),
sa.ForeignKeyConstraint(['hunt_id'], ['hunts.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_table('dataset_rows',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('dataset_id', sa.String(length=32), nullable=False),
sa.Column('row_index', sa.Integer(), nullable=False),
sa.Column('data', sa.JSON(), nullable=False),
sa.Column('normalized_data', sa.JSON(), nullable=True),
sa.ForeignKeyConstraint(['dataset_id'], ['datasets.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('dataset_rows', schema=None) as batch_op:
batch_op.create_index('ix_dataset_rows_dataset', ['dataset_id'], unique=False)
batch_op.create_index('ix_dataset_rows_dataset_idx', ['dataset_id', 'row_index'], unique=False)
op.create_table('enrichment_results',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('ioc_value', sa.String(length=512), nullable=False),
sa.Column('ioc_type', sa.String(length=32), nullable=False),
sa.Column('source', sa.String(length=32), nullable=False),
sa.Column('verdict', sa.String(length=16), nullable=True),
sa.Column('confidence', sa.Float(), nullable=True),
sa.Column('raw_result', sa.JSON(), nullable=True),
sa.Column('summary', sa.Text(), nullable=True),
sa.Column('dataset_id', sa.String(length=32), nullable=True),
sa.Column('cached_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['dataset_id'], ['datasets.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('enrichment_results', schema=None) as batch_op:
batch_op.create_index('ix_enrichment_ioc_source', ['ioc_value', 'source'], unique=False)
batch_op.create_index(batch_op.f('ix_enrichment_results_ioc_value'), ['ioc_value'], unique=False)
op.create_table('annotations',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('row_id', sa.Integer(), nullable=True),
sa.Column('dataset_id', sa.String(length=32), nullable=True),
sa.Column('author_id', sa.String(length=32), nullable=True),
sa.Column('text', sa.Text(), nullable=False),
sa.Column('severity', sa.String(length=16), nullable=False),
sa.Column('tag', sa.String(length=32), nullable=True),
sa.Column('highlight_color', sa.String(length=16), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['author_id'], ['users.id'], ),
sa.ForeignKeyConstraint(['dataset_id'], ['datasets.id'], ),
sa.ForeignKeyConstraint(['row_id'], ['dataset_rows.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('annotations', schema=None) as batch_op:
batch_op.create_index('ix_annotations_dataset', ['dataset_id'], unique=False)
batch_op.create_index('ix_annotations_row', ['row_id'], unique=False)
op.create_table('messages',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('conversation_id', sa.String(length=32), nullable=False),
sa.Column('role', sa.String(length=16), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('model_used', sa.String(length=128), nullable=True),
sa.Column('node_used', sa.String(length=64), nullable=True),
sa.Column('token_count', sa.Integer(), nullable=True),
sa.Column('latency_ms', sa.Integer(), nullable=True),
sa.Column('response_meta', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['conversation_id'], ['conversations.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('messages', schema=None) as batch_op:
batch_op.create_index('ix_messages_conversation', ['conversation_id'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('messages', schema=None) as batch_op:
batch_op.drop_index('ix_messages_conversation')
op.drop_table('messages')
with op.batch_alter_table('annotations', schema=None) as batch_op:
batch_op.drop_index('ix_annotations_row')
batch_op.drop_index('ix_annotations_dataset')
op.drop_table('annotations')
with op.batch_alter_table('enrichment_results', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_enrichment_results_ioc_value'))
batch_op.drop_index('ix_enrichment_ioc_source')
op.drop_table('enrichment_results')
with op.batch_alter_table('dataset_rows', schema=None) as batch_op:
batch_op.drop_index('ix_dataset_rows_dataset_idx')
batch_op.drop_index('ix_dataset_rows_dataset')
op.drop_table('dataset_rows')
op.drop_table('conversations')
with op.batch_alter_table('hypotheses', schema=None) as batch_op:
batch_op.drop_index('ix_hypotheses_hunt')
op.drop_table('hypotheses')
with op.batch_alter_table('datasets', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_datasets_name'))
batch_op.drop_index('ix_datasets_hunt')
op.drop_table('datasets')
op.drop_table('hunts')
with op.batch_alter_table('users', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_users_username'))
op.drop_table('users')
# ### end Alembic commands ###

View File

@@ -0,0 +1,64 @@
"""add_keyword_themes_and_keywords_tables
Revision ID: 98ab619418bc
Revises: 9790f482da06
Create Date: 2026-02-19 12:01:38.174653
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '98ab619418bc'
down_revision: Union[str, Sequence[str], None] = '9790f482da06'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('keyword_themes',
sa.Column('id', sa.String(length=32), nullable=False),
sa.Column('name', sa.String(length=128), nullable=False),
sa.Column('color', sa.String(length=16), nullable=False),
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('is_builtin', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('keyword_themes', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_keyword_themes_name'), ['name'], unique=True)
op.create_table('keywords',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('theme_id', sa.String(length=32), nullable=False),
sa.Column('value', sa.String(length=256), nullable=False),
sa.Column('is_regex', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['theme_id'], ['keyword_themes.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('keywords', schema=None) as batch_op:
batch_op.create_index('ix_keywords_theme', ['theme_id'], unique=False)
batch_op.create_index('ix_keywords_value', ['value'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('keywords', schema=None) as batch_op:
batch_op.drop_index('ix_keywords_value')
batch_op.drop_index('ix_keywords_theme')
op.drop_table('keywords')
with op.batch_alter_table('keyword_themes', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_keyword_themes_name'))
op.drop_table('keyword_themes')
# ### end Alembic commands ###

View File

@@ -1,105 +0,0 @@
"""Add Phase 2 tables
Revision ID: a1b2c3d4e5f6
Revises: f82b3092d056
Create Date: 2025-12-09 17:28:20.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, Sequence[str], None] = 'f82b3092d056'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema for Phase 2."""
# Add new fields to users table
op.add_column('users', sa.Column('email', sa.String(), nullable=True))
op.add_column('users', sa.Column('email_verified', sa.Boolean(), nullable=False, server_default='false'))
op.add_column('users', sa.Column('totp_secret', sa.String(), nullable=True))
op.add_column('users', sa.Column('totp_enabled', sa.Boolean(), nullable=False, server_default='false'))
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
# Create refresh_tokens table
op.create_table(
'refresh_tokens',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('token', sa.String(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('is_revoked', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_refresh_tokens_id'), 'refresh_tokens', ['id'], unique=False)
op.create_index(op.f('ix_refresh_tokens_token'), 'refresh_tokens', ['token'], unique=True)
# Create password_reset_tokens table
op.create_table(
'password_reset_tokens',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('token', sa.String(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('is_used', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_password_reset_tokens_id'), 'password_reset_tokens', ['id'], unique=False)
op.create_index(op.f('ix_password_reset_tokens_token'), 'password_reset_tokens', ['token'], unique=True)
# Create audit_logs table
op.create_table(
'audit_logs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=True),
sa.Column('tenant_id', sa.Integer(), nullable=False),
sa.Column('action', sa.String(), nullable=False),
sa.Column('resource_type', sa.String(), nullable=False),
sa.Column('resource_id', sa.Integer(), nullable=True),
sa.Column('details', sa.JSON(), nullable=True),
sa.Column('ip_address', sa.String(), nullable=True),
sa.Column('user_agent', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_audit_logs_id'), 'audit_logs', ['id'], unique=False)
op.create_index(op.f('ix_audit_logs_created_at'), 'audit_logs', ['created_at'], unique=False)
def downgrade() -> None:
"""Downgrade schema for Phase 2."""
# Drop audit_logs table
op.drop_index(op.f('ix_audit_logs_created_at'), table_name='audit_logs')
op.drop_index(op.f('ix_audit_logs_id'), table_name='audit_logs')
op.drop_table('audit_logs')
# Drop password_reset_tokens table
op.drop_index(op.f('ix_password_reset_tokens_token'), table_name='password_reset_tokens')
op.drop_index(op.f('ix_password_reset_tokens_id'), table_name='password_reset_tokens')
op.drop_table('password_reset_tokens')
# Drop refresh_tokens table
op.drop_index(op.f('ix_refresh_tokens_token'), table_name='refresh_tokens')
op.drop_index(op.f('ix_refresh_tokens_id'), table_name='refresh_tokens')
op.drop_table('refresh_tokens')
# Remove new fields from users table
op.drop_index(op.f('ix_users_email'), table_name='users')
op.drop_column('users', 'totp_enabled')
op.drop_column('users', 'totp_secret')
op.drop_column('users', 'email_verified')
op.drop_column('users', 'email')

View File

@@ -0,0 +1,112 @@
"""add processing_status and AI analysis tables
Revision ID: a1b2c3d4e5f6
Revises: 98ab619418bc
Create Date: 2026-02-19 18:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "a1b2c3d4e5f6"
down_revision: Union[str, Sequence[str], None] = "98ab619418bc"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add columns to datasets table
with op.batch_alter_table("datasets") as batch_op:
batch_op.add_column(sa.Column("processing_status", sa.String(20), server_default="ready"))
batch_op.add_column(sa.Column("artifact_type", sa.String(128), nullable=True))
batch_op.add_column(sa.Column("error_message", sa.Text(), nullable=True))
batch_op.add_column(sa.Column("file_path", sa.String(512), nullable=True))
batch_op.create_index("ix_datasets_status", ["processing_status"])
# Create triage_results table
op.create_table(
"triage_results",
sa.Column("id", sa.String(32), primary_key=True),
sa.Column("dataset_id", sa.String(32), sa.ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("row_start", sa.Integer(), nullable=False),
sa.Column("row_end", sa.Integer(), nullable=False),
sa.Column("risk_score", sa.Float(), nullable=False, server_default="0.0"),
sa.Column("verdict", sa.String(20), nullable=False, server_default="pending"),
sa.Column("findings", sa.JSON(), nullable=True),
sa.Column("suspicious_indicators", sa.JSON(), nullable=True),
sa.Column("mitre_techniques", sa.JSON(), nullable=True),
sa.Column("model_used", sa.String(128), nullable=True),
sa.Column("node_used", sa.String(64), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
# Create host_profiles table
op.create_table(
"host_profiles",
sa.Column("id", sa.String(32), primary_key=True),
sa.Column("hunt_id", sa.String(32), sa.ForeignKey("hunts.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("hostname", sa.String(256), nullable=False),
sa.Column("fqdn", sa.String(512), nullable=True),
sa.Column("client_id", sa.String(64), nullable=True),
sa.Column("risk_score", sa.Float(), nullable=False, server_default="0.0"),
sa.Column("risk_level", sa.String(20), nullable=False, server_default="unknown"),
sa.Column("artifact_summary", sa.JSON(), nullable=True),
sa.Column("timeline_summary", sa.Text(), nullable=True),
sa.Column("suspicious_findings", sa.JSON(), nullable=True),
sa.Column("mitre_techniques", sa.JSON(), nullable=True),
sa.Column("llm_analysis", sa.Text(), nullable=True),
sa.Column("model_used", sa.String(128), nullable=True),
sa.Column("node_used", sa.String(64), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
# Create hunt_reports table
op.create_table(
"hunt_reports",
sa.Column("id", sa.String(32), primary_key=True),
sa.Column("hunt_id", sa.String(32), sa.ForeignKey("hunts.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("status", sa.String(20), nullable=False, server_default="pending"),
sa.Column("exec_summary", sa.Text(), nullable=True),
sa.Column("full_report", sa.Text(), nullable=True),
sa.Column("findings", sa.JSON(), nullable=True),
sa.Column("recommendations", sa.JSON(), nullable=True),
sa.Column("mitre_mapping", sa.JSON(), nullable=True),
sa.Column("ioc_table", sa.JSON(), nullable=True),
sa.Column("host_risk_summary", sa.JSON(), nullable=True),
sa.Column("models_used", sa.JSON(), nullable=True),
sa.Column("generation_time_ms", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
# Create anomaly_results table
op.create_table(
"anomaly_results",
sa.Column("id", sa.String(32), primary_key=True),
sa.Column("dataset_id", sa.String(32), sa.ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False, index=True),
sa.Column("row_id", sa.String(32), sa.ForeignKey("dataset_rows.id", ondelete="CASCADE"), nullable=True),
sa.Column("anomaly_score", sa.Float(), nullable=False, server_default="0.0"),
sa.Column("distance_from_centroid", sa.Float(), nullable=True),
sa.Column("cluster_id", sa.Integer(), nullable=True),
sa.Column("is_outlier", sa.Boolean(), nullable=False, server_default="0"),
sa.Column("explanation", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table("anomaly_results")
op.drop_table("hunt_reports")
op.drop_table("host_profiles")
op.drop_table("triage_results")
with op.batch_alter_table("datasets") as batch_op:
batch_op.drop_index("ix_datasets_status")
batch_op.drop_column("file_path")
batch_op.drop_column("error_message")
batch_op.drop_column("artifact_type")
batch_op.drop_column("processing_status")

View File

@@ -1,50 +0,0 @@
"""Add Phase 3 tables
Revision ID: b2c3d4e5f6g7
Revises: a1b2c3d4e5f6
Create Date: 2025-12-09 17:30:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b2c3d4e5f6g7'
down_revision: Union[str, Sequence[str], None] = 'a1b2c3d4e5f6'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema for Phase 3."""
# Create notifications table
op.create_table(
'notifications',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('message', sa.Text(), nullable=False),
sa.Column('notification_type', sa.String(), nullable=False),
sa.Column('is_read', sa.Boolean(), nullable=False),
sa.Column('link', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_notifications_id'), 'notifications', ['id'], unique=False)
op.create_index(op.f('ix_notifications_created_at'), 'notifications', ['created_at'], unique=False)
def downgrade() -> None:
"""Downgrade schema for Phase 3."""
# Drop notifications table
op.drop_index(op.f('ix_notifications_created_at'), table_name='notifications')
op.drop_index(op.f('ix_notifications_id'), table_name='notifications')
op.drop_table('notifications')

View File

@@ -1,152 +0,0 @@
"""Add Phase 4 tables
Revision ID: c3d4e5f6g7h8
Revises: b2c3d4e5f6g7
Create Date: 2025-12-09 17:35:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'c3d4e5f6g7h8'
down_revision: Union[str, Sequence[str], None] = 'b2c3d4e5f6g7'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema for Phase 4."""
# Create playbooks table
op.create_table(
'playbooks',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('trigger_type', sa.String(), nullable=False),
sa.Column('trigger_config', sa.JSON(), nullable=True),
sa.Column('actions', sa.JSON(), nullable=False),
sa.Column('is_enabled', sa.Boolean(), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_playbooks_id'), 'playbooks', ['id'], unique=False)
op.create_index(op.f('ix_playbooks_name'), 'playbooks', ['name'], unique=False)
# Create playbook_executions table
op.create_table(
'playbook_executions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('playbook_id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=False),
sa.Column('status', sa.String(), nullable=False),
sa.Column('started_at', sa.DateTime(), nullable=True),
sa.Column('completed_at', sa.DateTime(), nullable=True),
sa.Column('result', sa.JSON(), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('triggered_by', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['playbook_id'], ['playbooks.id'], ),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.ForeignKeyConstraint(['triggered_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_playbook_executions_id'), 'playbook_executions', ['id'], unique=False)
# Create threat_scores table
op.create_table(
'threat_scores',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=False),
sa.Column('host_id', sa.Integer(), nullable=True),
sa.Column('artifact_id', sa.Integer(), nullable=True),
sa.Column('score', sa.Float(), nullable=False),
sa.Column('confidence', sa.Float(), nullable=False),
sa.Column('threat_type', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('indicators', sa.JSON(), nullable=True),
sa.Column('ml_model_version', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.ForeignKeyConstraint(['host_id'], ['hosts.id'], ),
sa.ForeignKeyConstraint(['artifact_id'], ['artifacts.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_threat_scores_id'), 'threat_scores', ['id'], unique=False)
op.create_index(op.f('ix_threat_scores_score'), 'threat_scores', ['score'], unique=False)
op.create_index(op.f('ix_threat_scores_created_at'), 'threat_scores', ['created_at'], unique=False)
# Create report_templates table
op.create_table(
'report_templates',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('template_type', sa.String(), nullable=False),
sa.Column('template_config', sa.JSON(), nullable=False),
sa.Column('is_default', sa.Boolean(), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_report_templates_id'), 'report_templates', ['id'], unique=False)
op.create_index(op.f('ix_report_templates_name'), 'report_templates', ['name'], unique=False)
# Create reports table
op.create_table(
'reports',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=False),
sa.Column('template_id', sa.Integer(), nullable=True),
sa.Column('title', sa.String(), nullable=False),
sa.Column('report_type', sa.String(), nullable=False),
sa.Column('format', sa.String(), nullable=False),
sa.Column('file_path', sa.String(), nullable=True),
sa.Column('status', sa.String(), nullable=False),
sa.Column('generated_by', sa.Integer(), nullable=False),
sa.Column('generated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.ForeignKeyConstraint(['template_id'], ['report_templates.id'], ),
sa.ForeignKeyConstraint(['generated_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_reports_id'), 'reports', ['id'], unique=False)
def downgrade() -> None:
"""Downgrade schema for Phase 4."""
# Drop reports table
op.drop_index(op.f('ix_reports_id'), table_name='reports')
op.drop_table('reports')
# Drop report_templates table
op.drop_index(op.f('ix_report_templates_name'), table_name='report_templates')
op.drop_index(op.f('ix_report_templates_id'), table_name='report_templates')
op.drop_table('report_templates')
# Drop threat_scores table
op.drop_index(op.f('ix_threat_scores_created_at'), table_name='threat_scores')
op.drop_index(op.f('ix_threat_scores_score'), table_name='threat_scores')
op.drop_index(op.f('ix_threat_scores_id'), table_name='threat_scores')
op.drop_table('threat_scores')
# Drop playbook_executions table
op.drop_index(op.f('ix_playbook_executions_id'), table_name='playbook_executions')
op.drop_table('playbook_executions')
# Drop playbooks table
op.drop_index(op.f('ix_playbooks_name'), table_name='playbooks')
op.drop_index(op.f('ix_playbooks_id'), table_name='playbooks')
op.drop_table('playbooks')

View File

@@ -1,114 +0,0 @@
"""Initial migration
Revision ID: f82b3092d056
Revises:
Create Date: 2025-12-09 14:25:47.222289
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f82b3092d056'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# Create tenants table
op.create_table(
'tenants',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(), nullable=False),
sa.Column('description', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_tenants_id'), 'tenants', ['id'], unique=False)
op.create_index(op.f('ix_tenants_name'), 'tenants', ['name'], unique=True)
# Create users table
op.create_table(
'users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(), nullable=False),
sa.Column('password_hash', sa.String(), nullable=False),
sa.Column('role', sa.String(), nullable=False),
sa.Column('tenant_id', sa.Integer(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
# Create hosts table
op.create_table(
'hosts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('hostname', sa.String(), nullable=False),
sa.Column('ip_address', sa.String(), nullable=True),
sa.Column('os', sa.String(), nullable=True),
sa.Column('tenant_id', sa.Integer(), nullable=False),
sa.Column('host_metadata', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('last_seen', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_hosts_id'), 'hosts', ['id'], unique=False)
op.create_index(op.f('ix_hosts_hostname'), 'hosts', ['hostname'], unique=False)
# Create cases table
op.create_table(
'cases',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('status', sa.String(), nullable=False),
sa.Column('severity', sa.String(), nullable=True),
sa.Column('tenant_id', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_cases_id'), 'cases', ['id'], unique=False)
# Create artifacts table
op.create_table(
'artifacts',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('artifact_type', sa.String(), nullable=False),
sa.Column('value', sa.String(), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('case_id', sa.Integer(), nullable=True),
sa.Column('artifact_metadata', sa.JSON(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['case_id'], ['cases.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_artifacts_id'), 'artifacts', ['id'], unique=False)
def downgrade() -> None:
"""Downgrade schema."""
op.drop_index(op.f('ix_artifacts_id'), table_name='artifacts')
op.drop_table('artifacts')
op.drop_index(op.f('ix_cases_id'), table_name='cases')
op.drop_table('cases')
op.drop_index(op.f('ix_hosts_hostname'), table_name='hosts')
op.drop_index(op.f('ix_hosts_id'), table_name='hosts')
op.drop_table('hosts')
op.drop_index(op.f('ix_users_username'), table_name='users')
op.drop_index(op.f('ix_users_id'), table_name='users')
op.drop_table('users')
op.drop_index(op.f('ix_tenants_name'), table_name='tenants')
op.drop_index(op.f('ix_tenants_id'), table_name='tenants')
op.drop_table('tenants')

View File

@@ -0,0 +1 @@
"""Backend initialization."""

View File

@@ -0,0 +1,67 @@
import asyncio
async def debated_generate(provider, prompt: str) -> str:
"""
Minimal behind-the-scenes debate.
Same logic for all apps.
Advisory only. No execution.
"""
planner = f"""
You are the Planner.
Give structured advisory guidance only.
No execution. No tools.
Request:
{prompt}
"""
critic = f"""
You are the Critic.
Identify risks, missing steps, and assumptions.
No execution. No tools.
Request:
{prompt}
"""
pragmatist = f"""
You are the Pragmatist.
Suggest the safest and simplest approach.
No execution. No tools.
Request:
{prompt}
"""
planner_task = provider.generate(planner)
critic_task = provider.generate(critic)
prag_task = provider.generate(pragmatist)
planner_resp, critic_resp, prag_resp = await asyncio.gather(
planner_task, critic_task, prag_task
)
judge = f"""
You are the Judge.
Merge the three responses into ONE final advisory answer.
Rules:
- Advisory only
- No execution
- Clearly list risks and assumptions
- Be concise
Planner:
{planner_resp}
Critic:
{critic_resp}
Pragmatist:
{prag_resp}
"""
final = await provider.generate(judge)
return final

View File

@@ -0,0 +1,16 @@
"""Analyst-assist agent module for ThreatHunt.
Provides read-only guidance on CSV artifact data, analytical pivots, and hypotheses.
Agents are advisory only and do not execute actions or modify data.
"""
from .core import ThreatHuntAgent
from .providers import LLMProvider, LocalProvider, NetworkedProvider, OnlineProvider
__all__ = [
"ThreatHuntAgent",
"LLMProvider",
"LocalProvider",
"NetworkedProvider",
"OnlineProvider",
]

View File

@@ -0,0 +1,59 @@
"""Configuration for agent settings."""
import os
from typing import Literal
class AgentConfig:
"""Configuration for analyst-assist agents."""
# Provider type: 'local', 'networked', 'online', or 'auto'
PROVIDER_TYPE: Literal["local", "networked", "online", "auto"] = os.getenv(
"THREAT_HUNT_AGENT_PROVIDER", "auto"
)
# Local provider settings
LOCAL_MODEL_PATH: str | None = os.getenv("THREAT_HUNT_LOCAL_MODEL_PATH")
# Networked provider settings
NETWORKED_ENDPOINT: str | None = os.getenv("THREAT_HUNT_NETWORKED_ENDPOINT")
NETWORKED_API_KEY: str | None = os.getenv("THREAT_HUNT_NETWORKED_KEY")
# Online provider settings
ONLINE_API_PROVIDER: str = os.getenv("THREAT_HUNT_ONLINE_PROVIDER", "openai")
ONLINE_API_KEY: str | None = os.getenv("THREAT_HUNT_ONLINE_API_KEY")
ONLINE_MODEL: str | None = os.getenv("THREAT_HUNT_ONLINE_MODEL")
# Agent behavior settings
MAX_RESPONSE_TOKENS: int = int(
os.getenv("THREAT_HUNT_AGENT_MAX_TOKENS", "1024")
)
ENABLE_REASONING: bool = os.getenv(
"THREAT_HUNT_AGENT_REASONING", "true"
).lower() in ("true", "1", "yes")
CONVERSATION_HISTORY_LENGTH: int = int(
os.getenv("THREAT_HUNT_AGENT_HISTORY_LENGTH", "10")
)
# Privacy settings
FILTER_SENSITIVE_DATA: bool = os.getenv(
"THREAT_HUNT_AGENT_FILTER_SENSITIVE", "true"
).lower() in ("true", "1", "yes")
@classmethod
def is_agent_enabled(cls) -> bool:
"""Check if agent is enabled and properly configured."""
# Agent is disabled if no provider can be used
if cls.PROVIDER_TYPE == "auto":
return bool(
cls.LOCAL_MODEL_PATH
or cls.NETWORKED_ENDPOINT
or cls.ONLINE_API_KEY
)
elif cls.PROVIDER_TYPE == "local":
return bool(cls.LOCAL_MODEL_PATH)
elif cls.PROVIDER_TYPE == "networked":
return bool(cls.NETWORKED_ENDPOINT)
elif cls.PROVIDER_TYPE == "online":
return bool(cls.ONLINE_API_KEY)
return False

208
backend/app/agents/core.py Normal file
View File

@@ -0,0 +1,208 @@
"""Core ThreatHunt analyst-assist agent.
Provides read-only guidance on CSV artifact data, analytical pivots, and hypotheses.
Agents are advisory only - no execution, no alerts, no data modifications.
"""
import logging
from typing import Optional
from pydantic import BaseModel, Field
from .providers import LLMProvider, get_provider
logger = logging.getLogger(__name__)
class AgentContext(BaseModel):
"""Context for agent guidance requests."""
query: str = Field(
..., description="Analyst question or request for guidance"
)
dataset_name: Optional[str] = Field(None, description="Name of CSV dataset")
artifact_type: Optional[str] = Field(None, description="Artifact type (e.g., file, process, network)")
host_identifier: Optional[str] = Field(
None, description="Host name, IP, or identifier"
)
data_summary: Optional[str] = Field(
None, description="Brief description of uploaded data"
)
conversation_history: Optional[list[dict]] = Field(
default_factory=list, description="Previous messages in conversation"
)
class AgentResponse(BaseModel):
"""Response from analyst-assist agent."""
guidance: str = Field(..., description="Advisory guidance for analyst")
confidence: float = Field(
..., ge=0.0, le=1.0, description="Confidence in guidance (0-1)"
)
suggested_pivots: list[str] = Field(
default_factory=list, description="Suggested analytical directions"
)
suggested_filters: list[str] = Field(
default_factory=list, description="Suggested data filters or queries"
)
caveats: Optional[str] = Field(
None, description="Assumptions, limitations, or caveats"
)
reasoning: Optional[str] = Field(
None, description="Explanation of how guidance was generated"
)
class ThreatHuntAgent:
"""Analyst-assist agent for ThreatHunt.
Provides guidance on:
- Interpreting CSV artifact data
- Suggesting analytical pivots and filters
- Forming and testing hypotheses
Policy:
- Advisory guidance only (no execution)
- No database or schema changes
- No alert escalation
- Transparent reasoning
"""
def __init__(self, provider: Optional[LLMProvider] = None):
"""Initialize agent with LLM provider.
Args:
provider: LLM provider instance. If None, uses get_provider() with auto mode.
"""
if provider is None:
try:
provider = get_provider("auto")
except RuntimeError as e:
logger.warning(f"Could not initialize default provider: {e}")
provider = None
self.provider = provider
self.system_prompt = self._build_system_prompt()
def _build_system_prompt(self) -> str:
"""Build the system prompt that governs agent behavior."""
return """You are an analyst-assist agent for ThreatHunt, a threat hunting platform.
Your role:
- Interpret and explain CSV artifact data from Velociraptor
- Suggest analytical pivots, filters, and hypotheses
- Highlight anomalies, patterns, or points of interest
- Guide analysts without replacing their judgment
Your constraints:
- You ONLY provide guidance and suggestions
- You do NOT execute actions or tools
- You do NOT modify data or escalate alerts
- You do NOT make autonomous decisions
- You ONLY analyze data presented to you
- You explain your reasoning transparently
- You acknowledge limitations and assumptions
- You suggest next investigative steps
When responding:
1. Start with a clear, direct answer to the query
2. Explain your reasoning based on the data context provided
3. Suggest 2-4 analytical pivots the analyst might explore
4. Suggest 2-4 data filters or queries that might be useful
5. Include relevant caveats or assumptions
6. Be honest about what you cannot determine from the data
Remember: The analyst is the decision-maker. You are an assistant."""
async def assist(self, context: AgentContext) -> AgentResponse:
"""Provide guidance on artifact data and analysis.
Args:
context: Request context including query and data context.
Returns:
Guidance response with suggestions and reasoning.
Raises:
RuntimeError: If no provider is available.
"""
if not self.provider:
raise RuntimeError(
"No LLM provider available. Configure at least one of: "
"THREAT_HUNT_LOCAL_MODEL_PATH, THREAT_HUNT_NETWORKED_ENDPOINT, "
"or THREAT_HUNT_ONLINE_API_KEY"
)
# Build prompt with context
prompt = self._build_prompt(context)
try:
# Get guidance from LLM provider
guidance = await self.provider.generate(prompt, max_tokens=1024)
# Parse response into structured format
response = self._parse_response(guidance, context)
logger.info(
f"Agent assisted with query: {context.query[:50]}... "
f"(dataset: {context.dataset_name})"
)
return response
except Exception as e:
logger.error(f"Error generating guidance: {e}")
raise
def _build_prompt(self, context: AgentContext) -> str:
"""Build the prompt for the LLM."""
prompt_parts = [
f"Analyst query: {context.query}",
]
if context.dataset_name:
prompt_parts.append(f"Dataset: {context.dataset_name}")
if context.artifact_type:
prompt_parts.append(f"Artifact type: {context.artifact_type}")
if context.host_identifier:
prompt_parts.append(f"Host: {context.host_identifier}")
if context.data_summary:
prompt_parts.append(f"Data summary: {context.data_summary}")
if context.conversation_history:
prompt_parts.append("\nConversation history:")
for msg in context.conversation_history[-5:]: # Last 5 messages for context
prompt_parts.append(f" {msg.get('role', 'unknown')}: {msg.get('content', '')}")
return "\n".join(prompt_parts)
def _parse_response(self, response_text: str, context: AgentContext) -> AgentResponse:
"""Parse LLM response into structured format.
Note: This is a simplified parser. In production, use structured output
from the LLM (JSON mode, function calling, etc.) for better reliability.
"""
# For now, return a structured response based on the raw guidance
# In production, parse JSON or use structured output from LLM
return AgentResponse(
guidance=response_text,
confidence=0.8, # Placeholder
suggested_pivots=[
"Analyze temporal patterns",
"Cross-reference with known indicators",
"Examine outliers in the dataset",
"Compare with baseline behavior",
],
suggested_filters=[
"Filter by high-risk indicators",
"Sort by timestamp for timeline analysis",
"Group by host or user",
"Filter by anomaly score",
],
caveats="Guidance is based on available data context. "
"Analysts should verify findings with additional sources.",
reasoning="Analysis generated based on artifact data patterns and analyst query.",
)

View File

@@ -0,0 +1,408 @@
"""Core ThreatHunt analyst-assist agent — v2.
Uses TaskRouter to select the right model/node for each query,
real LLM providers (Ollama/OpenWebUI), and structured response parsing.
Integrates SANS RAG context from Open WebUI.
"""
import json
import logging
import re
import time
from typing import AsyncIterator, Optional
from pydantic import BaseModel, Field
from app.config import settings
from app.services.sans_rag import sans_rag
from .router import TaskRouter, TaskType, RoutingDecision, task_router
from .providers_v2 import OllamaProvider, OpenWebUIProvider
logger = logging.getLogger(__name__)
# ── Models ────────────────────────────────────────────────────────────
class AgentContext(BaseModel):
"""Context for agent guidance requests."""
query: str = Field(..., description="Analyst question or request for guidance")
dataset_name: Optional[str] = Field(None, description="Name of CSV dataset")
artifact_type: Optional[str] = Field(None, description="Artifact type")
host_identifier: Optional[str] = Field(None, description="Host name, IP, or identifier")
data_summary: Optional[str] = Field(None, description="Brief description of data")
conversation_history: Optional[list[dict]] = Field(
default_factory=list, description="Previous messages"
)
active_hypotheses: Optional[list[str]] = Field(
default_factory=list, description="Active investigation hypotheses"
)
annotations_summary: Optional[str] = Field(
None, description="Summary of analyst annotations"
)
enrichment_summary: Optional[str] = Field(
None, description="Summary of enrichment results"
)
mode: str = Field(default="quick", description="quick | deep | debate")
model_override: Optional[str] = Field(None, description="Force a specific model")
class Perspective(BaseModel):
"""A single perspective from the debate agent."""
role: str
content: str
model_used: str
node_used: str
latency_ms: int
class AgentResponse(BaseModel):
"""Response from analyst-assist agent."""
guidance: str = Field(..., description="Advisory guidance for analyst")
confidence: float = Field(..., ge=0.0, le=1.0, description="Confidence (0-1)")
suggested_pivots: list[str] = Field(default_factory=list)
suggested_filters: list[str] = Field(default_factory=list)
caveats: Optional[str] = None
reasoning: Optional[str] = None
sans_references: list[str] = Field(
default_factory=list, description="SANS course references"
)
model_used: str = Field(default="", description="Model that generated the response")
node_used: str = Field(default="", description="Node that processed the request")
latency_ms: int = Field(default=0, description="Total latency in ms")
perspectives: Optional[list[Perspective]] = Field(
None, description="Debate perspectives (only in debate mode)"
)
# ── System prompt ─────────────────────────────────────────────────────
SYSTEM_PROMPT = """You are an analyst-assist agent for ThreatHunt, a threat hunting platform.
You have access to 300GB of SANS cybersecurity course material for reference.
Your role:
- Interpret and explain CSV artifact data from Velociraptor and other forensic tools
- Suggest analytical pivots, filters, and hypotheses
- Highlight anomalies, patterns, or points of interest
- Reference relevant SANS methodologies and techniques when applicable
- Guide analysts without replacing their judgment
Your constraints:
- You ONLY provide guidance and suggestions
- You do NOT execute actions or tools
- You do NOT modify data or escalate alerts
- You explain your reasoning transparently
RESPONSE FORMAT — you MUST respond with valid JSON:
{
"guidance": "Your main guidance text here",
"confidence": 0.85,
"suggested_pivots": ["Pivot 1", "Pivot 2"],
"suggested_filters": ["filter expression 1", "filter expression 2"],
"caveats": "Any assumptions or limitations",
"reasoning": "How you arrived at this guidance",
"sans_references": ["SANS SEC504: ...", "SANS FOR508: ..."]
}
Respond ONLY with the JSON object. No markdown, no code fences, no extra text."""
# ── Agent ─────────────────────────────────────────────────────────────
class ThreatHuntAgent:
"""Analyst-assist agent backed by Wile + Roadrunner LLM cluster."""
def __init__(self, router: TaskRouter | None = None):
self.router = router or task_router
self.system_prompt = SYSTEM_PROMPT
async def assist(self, context: AgentContext) -> AgentResponse:
"""Provide guidance on artifact data and analysis."""
start = time.monotonic()
if context.mode == "debate":
return await self._debate_assist(context)
# Classify task and route
task_type = self.router.classify_task(context.query)
if context.mode == "deep":
task_type = TaskType.DEEP_ANALYSIS
decision = self.router.route(task_type, model_override=context.model_override)
logger.info(f"Routing: {decision.reason}")
# Enrich prompt with SANS RAG context
prompt = self._build_prompt(context)
try:
rag_context = await sans_rag.enrich_prompt(
context.query,
investigation_context=context.data_summary or "",
)
if rag_context:
prompt = f"{prompt}\n\n{rag_context}"
except Exception as e:
logger.warning(f"SANS RAG enrichment failed: {e}")
# Call LLM
provider = self.router.get_provider(decision)
if isinstance(provider, OpenWebUIProvider):
messages = [
{"role": "system", "content": self.system_prompt},
{"role": "user", "content": prompt},
]
result = await provider.chat(
messages,
max_tokens=settings.AGENT_MAX_TOKENS,
temperature=settings.AGENT_TEMPERATURE,
)
else:
result = await provider.generate(
prompt,
system=self.system_prompt,
max_tokens=settings.AGENT_MAX_TOKENS,
temperature=settings.AGENT_TEMPERATURE,
)
raw_text = result.get("response", "")
latency_ms = result.get("_latency_ms", 0)
# Parse structured response
response = self._parse_response(raw_text, context)
response.model_used = decision.model
response.node_used = decision.node.value
response.latency_ms = latency_ms
total_ms = int((time.monotonic() - start) * 1000)
logger.info(
f"Agent assist: {context.query[:60]}... → "
f"{decision.model} on {decision.node.value} "
f"({total_ms}ms total, {latency_ms}ms LLM)"
)
return response
async def assist_stream(
self,
context: AgentContext,
) -> AsyncIterator[str]:
"""Stream agent response tokens."""
task_type = self.router.classify_task(context.query)
decision = self.router.route(task_type, model_override=context.model_override)
prompt = self._build_prompt(context)
provider = self.router.get_provider(decision)
if isinstance(provider, OllamaProvider):
async for token in provider.generate_stream(
prompt,
system=self.system_prompt,
max_tokens=settings.AGENT_MAX_TOKENS,
temperature=settings.AGENT_TEMPERATURE,
):
yield token
elif isinstance(provider, OpenWebUIProvider):
messages = [
{"role": "system", "content": self.system_prompt},
{"role": "user", "content": prompt},
]
async for token in provider.chat_stream(
messages,
max_tokens=settings.AGENT_MAX_TOKENS,
temperature=settings.AGENT_TEMPERATURE,
):
yield token
async def _debate_assist(self, context: AgentContext) -> AgentResponse:
"""Multi-perspective analysis using diverse models on Wile."""
import asyncio
start = time.monotonic()
prompt = self._build_prompt(context)
# Route each perspective to a different heavy model
roles = {
TaskType.DEBATE_PLANNER: (
"Planner",
"You are the Planner for a threat hunting investigation.\n"
"Provide a structured investigation strategy. Reference SANS methodologies.\n"
"Focus on: investigation steps, data sources to examine, MITRE ATT&CK mapping.\n"
"Be specific to the data context provided.\n\n",
),
TaskType.DEBATE_CRITIC: (
"Critic",
"You are the Critic for a threat hunting investigation.\n"
"Identify risks, false positive scenarios, missing evidence, and assumptions.\n"
"Reference SANS training on common analyst mistakes.\n"
"Challenge the obvious interpretation.\n\n",
),
TaskType.DEBATE_PRAGMATIST: (
"Pragmatist",
"You are the Pragmatist for a threat hunting investigation.\n"
"Suggest the most actionable, efficient next steps.\n"
"Reference SANS incident response playbooks.\n"
"Focus on: quick wins, triage priorities, what to escalate.\n\n",
),
}
async def _call_perspective(task_type: TaskType, role_name: str, prefix: str):
decision = self.router.route(task_type)
provider = self.router.get_provider(decision)
full_prompt = prefix + prompt
if isinstance(provider, OpenWebUIProvider):
result = await provider.generate(
full_prompt,
system=f"You are the {role_name}. Provide analysis only. No execution.",
max_tokens=settings.AGENT_MAX_TOKENS,
temperature=0.4,
)
else:
result = await provider.generate(
full_prompt,
system=f"You are the {role_name}. Provide analysis only. No execution.",
max_tokens=settings.AGENT_MAX_TOKENS,
temperature=0.4,
)
return Perspective(
role=role_name,
content=result.get("response", ""),
model_used=decision.model,
node_used=decision.node.value,
latency_ms=result.get("_latency_ms", 0),
)
# Run perspectives in parallel
perspective_tasks = [
_call_perspective(tt, name, prefix)
for tt, (name, prefix) in roles.items()
]
perspectives = await asyncio.gather(*perspective_tasks)
# Judge merges the perspectives
judge_prompt = (
"You are the Judge. Merge these three threat hunting perspectives into "
"ONE final advisory answer.\n\n"
"Rules:\n"
"- Advisory only — no execution\n"
"- Clearly list risks and assumptions\n"
"- Highlight where perspectives agree and disagree\n"
"- Provide a unified recommendation\n"
"- Reference SANS methodologies where relevant\n\n"
)
for p in perspectives:
judge_prompt += f"=== {p.role} (via {p.model_used}) ===\n{p.content}\n\n"
judge_prompt += (
f"\nOriginal analyst query:\n{context.query}\n\n"
"Respond with the merged analysis in this JSON format:\n"
'{"guidance": "...", "confidence": 0.85, "suggested_pivots": [...], '
'"suggested_filters": [...], "caveats": "...", "reasoning": "...", '
'"sans_references": [...]}'
)
judge_decision = self.router.route(TaskType.DEBATE_JUDGE)
judge_provider = self.router.get_provider(judge_decision)
if isinstance(judge_provider, OpenWebUIProvider):
judge_result = await judge_provider.generate(
judge_prompt,
system="You are the Judge. Merge perspectives into a final advisory answer. Respond with JSON only.",
max_tokens=settings.AGENT_MAX_TOKENS,
temperature=0.2,
)
else:
judge_result = await judge_provider.generate(
judge_prompt,
system="You are the Judge. Merge perspectives into a final advisory answer. Respond with JSON only.",
max_tokens=settings.AGENT_MAX_TOKENS,
temperature=0.2,
)
raw_text = judge_result.get("response", "")
response = self._parse_response(raw_text, context)
response.model_used = judge_decision.model
response.node_used = judge_decision.node.value
response.latency_ms = int((time.monotonic() - start) * 1000)
response.perspectives = list(perspectives)
return response
def _build_prompt(self, context: AgentContext) -> str:
"""Build the prompt with all available context."""
parts = [f"Analyst query: {context.query}"]
if context.dataset_name:
parts.append(f"Dataset: {context.dataset_name}")
if context.artifact_type:
parts.append(f"Artifact type: {context.artifact_type}")
if context.host_identifier:
parts.append(f"Host: {context.host_identifier}")
if context.data_summary:
parts.append(f"Data summary: {context.data_summary}")
if context.active_hypotheses:
parts.append(f"Active hypotheses: {'; '.join(context.active_hypotheses)}")
if context.annotations_summary:
parts.append(f"Analyst annotations: {context.annotations_summary}")
if context.enrichment_summary:
parts.append(f"Enrichment data: {context.enrichment_summary}")
if context.conversation_history:
parts.append("\nRecent conversation:")
for msg in context.conversation_history[-settings.AGENT_HISTORY_LENGTH:]:
parts.append(f" {msg.get('role', 'unknown')}: {msg.get('content', '')[:500]}")
return "\n".join(parts)
def _parse_response(self, raw: str, context: AgentContext) -> AgentResponse:
"""Parse LLM output into structured AgentResponse.
Tries JSON extraction first, falls back to raw text with defaults.
"""
parsed = self._try_parse_json(raw)
if parsed:
return AgentResponse(
guidance=parsed.get("guidance", raw),
confidence=min(max(float(parsed.get("confidence", 0.7)), 0.0), 1.0),
suggested_pivots=parsed.get("suggested_pivots", [])[:6],
suggested_filters=parsed.get("suggested_filters", [])[:6],
caveats=parsed.get("caveats"),
reasoning=parsed.get("reasoning"),
sans_references=parsed.get("sans_references", []),
)
# Fallback: use raw text as guidance
return AgentResponse(
guidance=raw.strip() or "No guidance generated. Please try rephrasing your question.",
confidence=0.5,
suggested_pivots=[],
suggested_filters=[],
caveats="Response was not in structured format. Pivots and filters may be embedded in the guidance text.",
reasoning=None,
sans_references=[],
)
def _try_parse_json(self, text: str) -> dict | None:
"""Try to extract JSON from LLM output."""
# Direct parse
try:
return json.loads(text.strip())
except json.JSONDecodeError:
pass
# Extract from code fences
patterns = [
r"```json\s*(.*?)\s*```",
r"```\s*(.*?)\s*```",
r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}",
]
for pattern in patterns:
match = re.search(pattern, text, re.DOTALL)
if match:
try:
return json.loads(match.group(1) if match.lastindex else match.group(0))
except (json.JSONDecodeError, IndexError):
continue
return None

View File

@@ -0,0 +1,190 @@
"""Pluggable LLM provider interface for analyst-assist agents.
Supports three provider types:
- Local: On-device or on-prem models
- Networked: Shared internal inference services
- Online: External hosted APIs
"""
import os
from abc import ABC, abstractmethod
from typing import Optional
class LLMProvider(ABC):
"""Abstract base class for LLM providers."""
@abstractmethod
async def generate(self, prompt: str, max_tokens: int = 1024) -> str:
"""Generate a response from the LLM.
Args:
prompt: The input prompt
max_tokens: Maximum tokens in response
Returns:
Generated text response
"""
pass
@abstractmethod
def is_available(self) -> bool:
"""Check if provider backend is available."""
pass
class LocalProvider(LLMProvider):
"""Local LLM provider (on-device or on-prem models)."""
def __init__(self, model_path: Optional[str] = None):
"""Initialize local provider.
Args:
model_path: Path to local model. If None, uses THREAT_HUNT_LOCAL_MODEL_PATH env var.
"""
self.model_path = model_path or os.getenv("THREAT_HUNT_LOCAL_MODEL_PATH")
self.model = None
def is_available(self) -> bool:
"""Check if local model is available."""
if not self.model_path:
return False
# In production, would verify model file exists and can be loaded
return os.path.exists(str(self.model_path))
async def generate(self, prompt: str, max_tokens: int = 1024) -> str:
"""Generate response using local model.
Note: This is a placeholder. In production, integrate with:
- llama-cpp-python for GGML models
- Ollama API
- vLLM
- Other local inference engines
"""
if not self.is_available():
raise RuntimeError("Local model not available")
# Placeholder implementation
return f"[Local model response to: {prompt[:50]}...]"
class NetworkedProvider(LLMProvider):
"""Networked LLM provider (shared internal inference services)."""
def __init__(
self,
api_endpoint: Optional[str] = None,
api_key: Optional[str] = None,
model_name: str = "default",
):
"""Initialize networked provider.
Args:
api_endpoint: URL to inference service. Defaults to env var THREAT_HUNT_NETWORKED_ENDPOINT.
api_key: API key for service. Defaults to env var THREAT_HUNT_NETWORKED_KEY.
model_name: Model name/ID on the service.
"""
self.api_endpoint = api_endpoint or os.getenv("THREAT_HUNT_NETWORKED_ENDPOINT")
self.api_key = api_key or os.getenv("THREAT_HUNT_NETWORKED_KEY")
self.model_name = model_name
def is_available(self) -> bool:
"""Check if networked service is available."""
return bool(self.api_endpoint)
async def generate(self, prompt: str, max_tokens: int = 1024) -> str:
"""Generate response using networked service.
Note: This is a placeholder. In production, integrate with:
- Internal inference service API
- LLM inference container cluster
- Enterprise inference gateway
"""
if not self.is_available():
raise RuntimeError("Networked service not available")
# Placeholder implementation
return f"[Networked response from {self.model_name}: {prompt[:50]}...]"
class OnlineProvider(LLMProvider):
"""Online LLM provider (external hosted APIs)."""
def __init__(
self,
api_provider: str = "openai",
api_key: Optional[str] = None,
model_name: Optional[str] = None,
):
"""Initialize online provider.
Args:
api_provider: Provider name (openai, anthropic, google, etc.)
api_key: API key. Defaults to env var THREAT_HUNT_ONLINE_API_KEY.
model_name: Model name. Defaults to env var THREAT_HUNT_ONLINE_MODEL.
"""
self.api_provider = api_provider
self.api_key = api_key or os.getenv("THREAT_HUNT_ONLINE_API_KEY")
self.model_name = model_name or os.getenv(
"THREAT_HUNT_ONLINE_MODEL", f"{api_provider}-default"
)
def is_available(self) -> bool:
"""Check if online API is available."""
return bool(self.api_key)
async def generate(self, prompt: str, max_tokens: int = 1024) -> str:
"""Generate response using online API.
Note: This is a placeholder. In production, integrate with:
- OpenAI API (GPT-3.5, GPT-4, etc.)
- Anthropic Claude API
- Google Gemini API
- Other hosted LLM services
"""
if not self.is_available():
raise RuntimeError("Online API not available or API key not set")
# Placeholder implementation
return f"[Online {self.api_provider} response: {prompt[:50]}...]"
def get_provider(provider_type: str = "auto") -> LLMProvider:
"""Get an LLM provider based on configuration.
Args:
provider_type: Type of provider to use: 'local', 'networked', 'online', or 'auto'.
'auto' attempts to use the first available provider in order:
local -> networked -> online.
Returns:
Configured LLM provider instance.
Raises:
RuntimeError: If no provider is available.
"""
# Explicit provider selection
if provider_type == "local":
provider = LocalProvider()
elif provider_type == "networked":
provider = NetworkedProvider()
elif provider_type == "online":
provider = OnlineProvider()
elif provider_type == "auto":
# Try providers in order of preference
for Provider in [LocalProvider, NetworkedProvider, OnlineProvider]:
provider = Provider()
if provider.is_available():
return provider
raise RuntimeError(
"No LLM provider available. Configure at least one of: "
"THREAT_HUNT_LOCAL_MODEL_PATH, THREAT_HUNT_NETWORKED_ENDPOINT, "
"or THREAT_HUNT_ONLINE_API_KEY"
)
else:
raise ValueError(f"Unknown provider type: {provider_type}")
if not provider.is_available():
raise RuntimeError(f"{provider_type} provider not available")
return provider

View File

@@ -0,0 +1,362 @@
"""LLM providers — real implementations for Ollama nodes and Open WebUI cluster.
Three providers:
- OllamaProvider: Direct calls to Ollama on Wile/Roadrunner via Tailscale
- OpenWebUIProvider: Calls to the Open WebUI cluster (OpenAI-compatible)
- EmbeddingProvider: Embedding generation via Ollama /api/embeddings
"""
import asyncio
import json
import logging
import time
from typing import AsyncIterator
import httpx
from app.config import settings
from .registry import ModelEntry, Node
logger = logging.getLogger(__name__)
# Shared HTTP client with reasonable timeouts
_client: httpx.AsyncClient | None = None
def _get_client() -> httpx.AsyncClient:
global _client
if _client is None or _client.is_closed:
_client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=10, read=300, write=30, pool=10),
limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
)
return _client
async def cleanup_client():
global _client
if _client and not _client.is_closed:
await _client.aclose()
_client = None
def _ollama_url(node: Node) -> str:
"""Get the Ollama base URL for a node."""
if node == Node.WILE:
return settings.wile_url
elif node == Node.ROADRUNNER:
return settings.roadrunner_url
else:
raise ValueError(f"No direct Ollama URL for node: {node}")
# ── Ollama Provider ──────────────────────────────────────────────────
class OllamaProvider:
"""Direct Ollama API calls to Wile or Roadrunner."""
def __init__(self, model: str, node: Node):
self.model = model
self.node = node
self.base_url = _ollama_url(node)
async def generate(
self,
prompt: str,
system: str = "",
max_tokens: int = 2048,
temperature: float = 0.3,
) -> dict:
"""Generate a completion. Returns dict with 'response', 'model', 'total_duration', etc."""
client = _get_client()
payload = {
"model": self.model,
"prompt": prompt,
"stream": False,
"options": {
"num_predict": max_tokens,
"temperature": temperature,
},
}
if system:
payload["system"] = system
start = time.monotonic()
try:
resp = await client.post(
f"{self.base_url}/api/generate",
json=payload,
)
resp.raise_for_status()
data = resp.json()
latency_ms = int((time.monotonic() - start) * 1000)
data["_latency_ms"] = latency_ms
data["_node"] = self.node.value
logger.info(
f"Ollama [{self.node.value}] {self.model}: "
f"{latency_ms}ms, {data.get('eval_count', '?')} tokens"
)
return data
except httpx.HTTPStatusError as e:
logger.error(f"Ollama HTTP error [{self.node.value}]: {e.response.status_code} {e.response.text[:200]}")
raise
except httpx.ConnectError as e:
logger.error(f"Cannot reach Ollama on {self.node.value} ({self.base_url}): {e}")
raise
async def chat(
self,
messages: list[dict],
max_tokens: int = 2048,
temperature: float = 0.3,
) -> dict:
"""Chat completion via Ollama /api/chat."""
client = _get_client()
payload = {
"model": self.model,
"messages": messages,
"stream": False,
"options": {
"num_predict": max_tokens,
"temperature": temperature,
},
}
start = time.monotonic()
resp = await client.post(f"{self.base_url}/api/chat", json=payload)
resp.raise_for_status()
data = resp.json()
data["_latency_ms"] = int((time.monotonic() - start) * 1000)
data["_node"] = self.node.value
return data
async def generate_stream(
self,
prompt: str,
system: str = "",
max_tokens: int = 2048,
temperature: float = 0.3,
) -> AsyncIterator[str]:
"""Stream tokens from Ollama."""
client = _get_client()
payload = {
"model": self.model,
"prompt": prompt,
"stream": True,
"options": {
"num_predict": max_tokens,
"temperature": temperature,
},
}
if system:
payload["system"] = system
async with client.stream(
"POST", f"{self.base_url}/api/generate", json=payload
) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if line.strip():
try:
chunk = json.loads(line)
token = chunk.get("response", "")
if token:
yield token
if chunk.get("done"):
break
except json.JSONDecodeError:
continue
async def is_available(self) -> bool:
"""Ping the Ollama node."""
try:
client = _get_client()
resp = await client.get(f"{self.base_url}/api/tags", timeout=5)
return resp.status_code == 200
except Exception:
return False
# ── Open WebUI Provider (OpenAI-compatible) ───────────────────────────
class OpenWebUIProvider:
"""Calls to Open WebUI cluster at ai.guapo613.beer.
Uses the OpenAI-compatible /v1/chat/completions endpoint.
"""
def __init__(self, model: str = ""):
self.model = model or settings.DEFAULT_FAST_MODEL
self.base_url = settings.OPENWEBUI_URL.rstrip("/")
self.api_key = settings.OPENWEBUI_API_KEY
def _headers(self) -> dict:
h = {"Content-Type": "application/json"}
if self.api_key:
h["Authorization"] = f"Bearer {self.api_key}"
return h
async def chat(
self,
messages: list[dict],
max_tokens: int = 2048,
temperature: float = 0.3,
) -> dict:
"""Chat completion via OpenAI-compatible endpoint."""
client = _get_client()
payload = {
"model": self.model,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
"stream": False,
}
start = time.monotonic()
resp = await client.post(
f"{self.base_url}/v1/chat/completions",
json=payload,
headers=self._headers(),
)
resp.raise_for_status()
data = resp.json()
latency_ms = int((time.monotonic() - start) * 1000)
# Normalize to our format
content = ""
if data.get("choices"):
content = data["choices"][0].get("message", {}).get("content", "")
result = {
"response": content,
"model": data.get("model", self.model),
"_latency_ms": latency_ms,
"_node": "cluster",
"_usage": data.get("usage", {}),
}
logger.info(
f"OpenWebUI cluster {self.model}: {latency_ms}ms"
)
return result
async def generate(
self,
prompt: str,
system: str = "",
max_tokens: int = 2048,
temperature: float = 0.3,
) -> dict:
"""Convert prompt-style call to chat format."""
messages = []
if system:
messages.append({"role": "system", "content": system})
messages.append({"role": "user", "content": prompt})
return await self.chat(messages, max_tokens, temperature)
async def chat_stream(
self,
messages: list[dict],
max_tokens: int = 2048,
temperature: float = 0.3,
) -> AsyncIterator[str]:
"""Stream tokens from OpenWebUI."""
client = _get_client()
payload = {
"model": self.model,
"messages": messages,
"max_tokens": max_tokens,
"temperature": temperature,
"stream": True,
}
async with client.stream(
"POST",
f"{self.base_url}/v1/chat/completions",
json=payload,
headers=self._headers(),
) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if line.startswith("data: "):
data_str = line[6:].strip()
if data_str == "[DONE]":
break
try:
chunk = json.loads(data_str)
delta = chunk.get("choices", [{}])[0].get("delta", {})
token = delta.get("content", "")
if token:
yield token
except json.JSONDecodeError:
continue
async def is_available(self) -> bool:
"""Check if Open WebUI is reachable."""
try:
client = _get_client()
resp = await client.get(
f"{self.base_url}/v1/models",
headers=self._headers(),
timeout=5,
)
return resp.status_code == 200
except Exception:
return False
# ── Embedding Provider ────────────────────────────────────────────────
class EmbeddingProvider:
"""Generate embeddings via Ollama /api/embeddings."""
def __init__(self, model: str = "", node: Node = Node.ROADRUNNER):
self.model = model or settings.DEFAULT_EMBEDDING_MODEL
self.node = node
self.base_url = _ollama_url(node)
async def embed(self, text: str) -> list[float]:
"""Get embedding vector for a single text."""
client = _get_client()
resp = await client.post(
f"{self.base_url}/api/embeddings",
json={"model": self.model, "prompt": text},
)
resp.raise_for_status()
data = resp.json()
return data.get("embedding", [])
async def embed_batch(self, texts: list[str], concurrency: int = 5) -> list[list[float]]:
"""Embed multiple texts with controlled concurrency."""
sem = asyncio.Semaphore(concurrency)
async def _embed_one(t: str) -> list[float]:
async with sem:
return await self.embed(t)
return await asyncio.gather(*[_embed_one(t) for t in texts])
# ── Health check for all nodes ────────────────────────────────────────
async def check_all_nodes() -> dict:
"""Check availability of all LLM nodes."""
wile = OllamaProvider("", Node.WILE)
roadrunner = OllamaProvider("", Node.ROADRUNNER)
cluster = OpenWebUIProvider()
wile_ok, rr_ok, cl_ok = await asyncio.gather(
wile.is_available(),
roadrunner.is_available(),
cluster.is_available(),
return_exceptions=True,
)
return {
"wile": {"available": wile_ok is True, "url": settings.wile_url},
"roadrunner": {"available": rr_ok is True, "url": settings.roadrunner_url},
"cluster": {"available": cl_ok is True, "url": settings.OPENWEBUI_URL},
}

View File

@@ -0,0 +1,161 @@
"""Model registry — inventory of all Ollama models across Wile and Roadrunner.
Each model is tagged with capabilities (chat, code, vision, embedding) and
performance tier (fast, medium, heavy) for the TaskRouter.
"""
from dataclasses import dataclass, field
from enum import Enum
class Capability(str, Enum):
CHAT = "chat"
CODE = "code"
VISION = "vision"
EMBEDDING = "embedding"
class Tier(str, Enum):
FAST = "fast" # < 15B params — quick responses
MEDIUM = "medium" # 1540B params — balanced
HEAVY = "heavy" # 40B+ params — deep analysis
class Node(str, Enum):
WILE = "wile"
ROADRUNNER = "roadrunner"
CLUSTER = "cluster" # Open WebUI balances across both
@dataclass
class ModelEntry:
name: str
node: Node
capabilities: list[Capability]
tier: Tier
param_size: str = "" # e.g. "7b", "70b"
notes: str = ""
# ── Roadrunner (100.110.190.11) ──────────────────────────────────────
ROADRUNNER_MODELS: list[ModelEntry] = [
# General / chat
ModelEntry("llama3.1:latest", Node.ROADRUNNER, [Capability.CHAT], Tier.FAST, "8b"),
ModelEntry("qwen2.5:14b-instruct", Node.ROADRUNNER, [Capability.CHAT], Tier.FAST, "14b"),
ModelEntry("mistral:7b-instruct", Node.ROADRUNNER, [Capability.CHAT], Tier.FAST, "7b"),
ModelEntry("mistral:7b", Node.ROADRUNNER, [Capability.CHAT], Tier.FAST, "7b"),
ModelEntry("qwen2.5:7b", Node.ROADRUNNER, [Capability.CHAT], Tier.FAST, "7b"),
ModelEntry("phi3:medium", Node.ROADRUNNER, [Capability.CHAT], Tier.MEDIUM, "14b"),
# Code
ModelEntry("qwen2.5-coder:7b", Node.ROADRUNNER, [Capability.CODE], Tier.FAST, "7b"),
ModelEntry("qwen2.5-coder:latest", Node.ROADRUNNER, [Capability.CODE], Tier.FAST, "7b"),
ModelEntry("codestral:latest", Node.ROADRUNNER, [Capability.CODE], Tier.MEDIUM, "22b"),
ModelEntry("codellama:13b", Node.ROADRUNNER, [Capability.CODE], Tier.FAST, "13b"),
# Vision
ModelEntry("llama3.2-vision:11b", Node.ROADRUNNER, [Capability.VISION], Tier.FAST, "11b"),
ModelEntry("minicpm-v:latest", Node.ROADRUNNER, [Capability.VISION], Tier.FAST, "8b"),
ModelEntry("llava:13b", Node.ROADRUNNER, [Capability.VISION], Tier.FAST, "13b"),
# Embeddings
ModelEntry("bge-m3:latest", Node.ROADRUNNER, [Capability.EMBEDDING], Tier.FAST, "0.6b"),
ModelEntry("nomic-embed-text:latest", Node.ROADRUNNER, [Capability.EMBEDDING], Tier.FAST, "0.1b"),
# Heavy
ModelEntry("llama3.1:70b-instruct-q4_K_M", Node.ROADRUNNER, [Capability.CHAT], Tier.HEAVY, "70b"),
]
# ── Wile (100.110.190.12) ────────────────────────────────────────────
WILE_MODELS: list[ModelEntry] = [
# General / chat
ModelEntry("llama3.1:latest", Node.WILE, [Capability.CHAT], Tier.FAST, "8b"),
ModelEntry("llama3:latest", Node.WILE, [Capability.CHAT], Tier.FAST, "8b"),
ModelEntry("gemma2:27b", Node.WILE, [Capability.CHAT], Tier.MEDIUM, "27b"),
# Code
ModelEntry("qwen2.5-coder:7b", Node.WILE, [Capability.CODE], Tier.FAST, "7b"),
ModelEntry("qwen2.5-coder:latest", Node.WILE, [Capability.CODE], Tier.FAST, "7b"),
ModelEntry("qwen2.5-coder:32b", Node.WILE, [Capability.CODE], Tier.MEDIUM, "32b"),
ModelEntry("deepseek-coder:33b", Node.WILE, [Capability.CODE], Tier.MEDIUM, "33b"),
ModelEntry("codestral:latest", Node.WILE, [Capability.CODE], Tier.MEDIUM, "22b"),
# Vision
ModelEntry("llava:13b", Node.WILE, [Capability.VISION], Tier.FAST, "13b"),
# Embeddings
ModelEntry("bge-m3:latest", Node.WILE, [Capability.EMBEDDING], Tier.FAST, "0.6b"),
# Heavy
ModelEntry("llama3.1:70b", Node.WILE, [Capability.CHAT], Tier.HEAVY, "70b"),
ModelEntry("llama3.1:70b-instruct-q4_K_M", Node.WILE, [Capability.CHAT], Tier.HEAVY, "70b"),
ModelEntry("llama3.1:70b-instruct-q5_K_M", Node.WILE, [Capability.CHAT], Tier.HEAVY, "70b"),
ModelEntry("mixtral:8x22b-instruct", Node.WILE, [Capability.CHAT], Tier.HEAVY, "141b"),
ModelEntry("qwen2:72b-instruct", Node.WILE, [Capability.CHAT], Tier.HEAVY, "72b"),
]
ALL_MODELS = ROADRUNNER_MODELS + WILE_MODELS
class ModelRegistry:
"""Registry of all available models and their capabilities."""
def __init__(self, models: list[ModelEntry] | None = None):
self.models = models or ALL_MODELS
self._by_name: dict[str, list[ModelEntry]] = {}
self._by_capability: dict[Capability, list[ModelEntry]] = {}
self._by_node: dict[Node, list[ModelEntry]] = {}
self._index()
def _index(self):
for m in self.models:
self._by_name.setdefault(m.name, []).append(m)
for cap in m.capabilities:
self._by_capability.setdefault(cap, []).append(m)
self._by_node.setdefault(m.node, []).append(m)
def find(
self,
capability: Capability | None = None,
tier: Tier | None = None,
node: Node | None = None,
) -> list[ModelEntry]:
"""Find models matching all given criteria."""
results = list(self.models)
if capability:
results = [m for m in results if capability in m.capabilities]
if tier:
results = [m for m in results if m.tier == tier]
if node:
results = [m for m in results if m.node == node]
return results
def get_best(
self,
capability: Capability,
prefer_tier: Tier | None = None,
prefer_node: Node | None = None,
) -> ModelEntry | None:
"""Get the best model for a capability, with optional preference."""
candidates = self.find(capability=capability, tier=prefer_tier, node=prefer_node)
if not candidates:
candidates = self.find(capability=capability, tier=prefer_tier)
if not candidates:
candidates = self.find(capability=capability)
return candidates[0] if candidates else None
def list_nodes(self) -> list[Node]:
return list(self._by_node.keys())
def list_models_on_node(self, node: Node) -> list[ModelEntry]:
return self._by_node.get(node, [])
def to_dict(self) -> list[dict]:
return [
{
"name": m.name,
"node": m.node.value,
"capabilities": [c.value for c in m.capabilities],
"tier": m.tier.value,
"param_size": m.param_size,
}
for m in self.models
]
# Singleton
registry = ModelRegistry()

View File

@@ -0,0 +1,183 @@
"""Task router — auto-selects the right model + node for each task type.
Routes based on task characteristics:
- Quick chat → fast models via cluster
- Deep analysis → 70B+ models on Wile
- Code/script analysis → code models (32b on Wile, 7b for quick)
- Vision/image → vision models on Roadrunner
- Embedding → embedding models on either node
"""
import logging
from dataclasses import dataclass
from enum import Enum
from app.config import settings
from .registry import Capability, Tier, Node, ModelEntry, registry
from .providers_v2 import OllamaProvider, OpenWebUIProvider, EmbeddingProvider
logger = logging.getLogger(__name__)
class TaskType(str, Enum):
QUICK_CHAT = "quick_chat"
DEEP_ANALYSIS = "deep_analysis"
CODE_ANALYSIS = "code_analysis"
VISION = "vision"
EMBEDDING = "embedding"
DEBATE_PLANNER = "debate_planner"
DEBATE_CRITIC = "debate_critic"
DEBATE_PRAGMATIST = "debate_pragmatist"
DEBATE_JUDGE = "debate_judge"
@dataclass
class RoutingDecision:
"""Result of the routing decision."""
model: str
node: Node
task_type: TaskType
provider_type: str # "ollama" or "openwebui"
reason: str
class TaskRouter:
"""Routes tasks to the appropriate model and node."""
# Default routing rules: task_type → (capability, preferred_tier, preferred_node)
ROUTING_RULES: dict[TaskType, tuple[Capability, Tier | None, Node | None]] = {
TaskType.QUICK_CHAT: (Capability.CHAT, Tier.FAST, None),
TaskType.DEEP_ANALYSIS: (Capability.CHAT, Tier.HEAVY, Node.WILE),
TaskType.CODE_ANALYSIS: (Capability.CODE, Tier.MEDIUM, Node.WILE),
TaskType.VISION: (Capability.VISION, None, Node.ROADRUNNER),
TaskType.EMBEDDING: (Capability.EMBEDDING, Tier.FAST, None),
TaskType.DEBATE_PLANNER: (Capability.CHAT, Tier.HEAVY, Node.WILE),
TaskType.DEBATE_CRITIC: (Capability.CHAT, Tier.HEAVY, Node.WILE),
TaskType.DEBATE_PRAGMATIST: (Capability.CHAT, Tier.HEAVY, Node.WILE),
TaskType.DEBATE_JUDGE: (Capability.CHAT, Tier.MEDIUM, Node.WILE),
}
# Specific model overrides for debate roles (use diverse models for diversity of thought)
DEBATE_MODEL_OVERRIDES: dict[TaskType, str] = {
TaskType.DEBATE_PLANNER: "llama3.1:70b-instruct-q4_K_M",
TaskType.DEBATE_CRITIC: "qwen2:72b-instruct",
TaskType.DEBATE_PRAGMATIST: "mixtral:8x22b-instruct",
TaskType.DEBATE_JUDGE: "gemma2:27b",
}
def __init__(self):
self.registry = registry
def route(self, task_type: TaskType, model_override: str | None = None) -> RoutingDecision:
"""Decide which model and node to use for a task."""
# Explicit model override
if model_override:
entries = self.registry.find()
for entry in entries:
if entry.name == model_override:
return RoutingDecision(
model=model_override,
node=entry.node,
task_type=task_type,
provider_type="ollama",
reason=f"Explicit model override: {model_override}",
)
# Model not in registry — try via cluster
return RoutingDecision(
model=model_override,
node=Node.CLUSTER,
task_type=task_type,
provider_type="openwebui",
reason=f"Override model {model_override} not in registry, routing to cluster",
)
# Debate model overrides
if task_type in self.DEBATE_MODEL_OVERRIDES:
model_name = self.DEBATE_MODEL_OVERRIDES[task_type]
entries = self.registry.find()
for entry in entries:
if entry.name == model_name:
return RoutingDecision(
model=model_name,
node=entry.node,
task_type=task_type,
provider_type="ollama",
reason=f"Debate role {task_type.value}{model_name} on {entry.node.value}",
)
# Standard routing
cap, tier, node = self.ROUTING_RULES.get(
task_type,
(Capability.CHAT, Tier.FAST, None),
)
entry = self.registry.get_best(cap, prefer_tier=tier, prefer_node=node)
if entry:
return RoutingDecision(
model=entry.name,
node=entry.node,
task_type=task_type,
provider_type="ollama",
reason=f"Auto-routed {task_type.value}: {cap.value}/{tier.value if tier else 'any'}{entry.name} on {entry.node.value}",
)
# Fallback to cluster
default_model = settings.DEFAULT_FAST_MODEL
return RoutingDecision(
model=default_model,
node=Node.CLUSTER,
task_type=task_type,
provider_type="openwebui",
reason=f"No registry match, falling back to cluster with {default_model}",
)
def get_provider(self, decision: RoutingDecision):
"""Create the appropriate provider for a routing decision."""
if decision.provider_type == "openwebui":
return OpenWebUIProvider(model=decision.model)
else:
return OllamaProvider(model=decision.model, node=decision.node)
def get_embedding_provider(self, model: str | None = None, node: Node | None = None) -> EmbeddingProvider:
"""Get an embedding provider."""
return EmbeddingProvider(
model=model or settings.DEFAULT_EMBEDDING_MODEL,
node=node or Node.ROADRUNNER,
)
def classify_task(self, query: str, has_image: bool = False) -> TaskType:
"""Heuristic classification of query into task type.
In practice this could be enhanced by a classifier model, but
keyword heuristics work well for routing.
"""
if has_image:
return TaskType.VISION
q = query.lower()
# Code/script indicators
code_indicators = [
"deobfuscate", "decode", "powershell", "script", "base64",
"command line", "cmdline", "commandline", "obfuscated",
"malware", "shellcode", "vbs", "vbscript", "batch",
"python script", "code review", "reverse engineer",
]
if any(ind in q for ind in code_indicators):
return TaskType.CODE_ANALYSIS
# Deep analysis indicators
deep_indicators = [
"deep analysis", "detailed", "comprehensive", "thorough",
"investigate", "root cause", "advanced", "explain in detail",
"full analysis", "forensic",
]
if any(ind in q for ind in deep_indicators):
return TaskType.DEEP_ANALYSIS
return TaskType.QUICK_CHAT
# Singleton
task_router = TaskRouter()

View File

@@ -0,0 +1 @@
"""API routes initialization."""

View File

@@ -0,0 +1 @@
"""API route modules."""

View File

@@ -0,0 +1,170 @@
"""API routes for analyst-assist agent."""
import logging
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.agents.core import ThreatHuntAgent, AgentContext, AgentResponse
from app.agents.config import AgentConfig
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/agent", tags=["agent"])
# Global agent instance (lazy-loaded)
_agent: ThreatHuntAgent | None = None
def get_agent() -> ThreatHuntAgent:
"""Get or create the agent instance."""
global _agent
if _agent is None:
if not AgentConfig.is_agent_enabled():
raise HTTPException(
status_code=503,
detail="Analyst-assist agent is not configured. "
"Please configure an LLM provider.",
)
_agent = ThreatHuntAgent()
return _agent
class AssistRequest(BaseModel):
"""Request for agent assistance."""
query: str = Field(
..., description="Analyst question or request for guidance"
)
dataset_name: str | None = Field(
None, description="Name of CSV dataset being analyzed"
)
artifact_type: str | None = Field(
None, description="Type of artifact (e.g., FileList, ProcessList, NetworkConnections)"
)
host_identifier: str | None = Field(
None, description="Host name, IP address, or identifier"
)
data_summary: str | None = Field(
None, description="Brief summary or context about the uploaded data"
)
conversation_history: list[dict] | None = Field(
None, description="Previous messages for context"
)
class AssistResponse(BaseModel):
"""Response with agent guidance."""
guidance: str
confidence: float
suggested_pivots: list[str]
suggested_filters: list[str]
caveats: str | None = None
reasoning: str | None = None
@router.post(
"/assist",
response_model=AssistResponse,
summary="Get analyst-assist guidance",
description="Request guidance on CSV artifact data, analytical pivots, and hypotheses. "
"Agent provides advisory guidance only - no execution.",
)
async def agent_assist(request: AssistRequest) -> AssistResponse:
"""Provide analyst-assist guidance on artifact data.
The agent will:
- Explain and interpret the provided data context
- Suggest analytical pivots the analyst might explore
- Suggest data filters or queries that might be useful
- Highlight assumptions, limitations, and caveats
The agent will NOT:
- Execute any tools or actions
- Escalate findings to alerts
- Modify any data or schema
- Make autonomous decisions
Args:
request: Assistance request with query and context
Returns:
Guidance response with suggestions and reasoning
Raises:
HTTPException: If agent is not configured (503) or request fails
"""
try:
agent = get_agent()
# Build context
context = AgentContext(
query=request.query,
dataset_name=request.dataset_name,
artifact_type=request.artifact_type,
host_identifier=request.host_identifier,
data_summary=request.data_summary,
conversation_history=request.conversation_history or [],
)
# Get guidance
response = await agent.assist(context)
logger.info(
f"Agent assisted analyst with query: {request.query[:50]}... "
f"(host: {request.host_identifier}, artifact: {request.artifact_type})"
)
return AssistResponse(
guidance=response.guidance,
confidence=response.confidence,
suggested_pivots=response.suggested_pivots,
suggested_filters=response.suggested_filters,
caveats=response.caveats,
reasoning=response.reasoning,
)
except RuntimeError as e:
logger.error(f"Agent error: {e}")
raise HTTPException(
status_code=503,
detail=f"Agent unavailable: {str(e)}",
)
except Exception as e:
logger.exception(f"Unexpected error in agent_assist: {e}")
raise HTTPException(
status_code=500,
detail="Error generating guidance. Please try again.",
)
@router.get(
"/health",
summary="Check agent health",
description="Check if agent is configured and ready to assist.",
)
async def agent_health() -> dict:
"""Check agent availability and configuration.
Returns:
Health status with configuration details
"""
try:
agent = get_agent()
provider_type = agent.provider.__class__.__name__ if agent.provider else "None"
return {
"status": "healthy",
"provider": provider_type,
"max_tokens": AgentConfig.MAX_RESPONSE_TOKENS,
"reasoning_enabled": AgentConfig.ENABLE_REASONING,
}
except HTTPException:
return {
"status": "unavailable",
"reason": "No LLM provider configured",
"configured_providers": {
"local": bool(AgentConfig.LOCAL_MODEL_PATH),
"networked": bool(AgentConfig.NETWORKED_ENDPOINT),
"online": bool(AgentConfig.ONLINE_API_KEY),
},
}

View File

@@ -0,0 +1,265 @@
"""API routes for analyst-assist agent — v2.
Supports quick, deep, and debate modes with streaming.
Conversations are persisted to the database.
"""
import json
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.db import get_db
from app.db.models import Conversation, Message
from app.agents.core_v2 import ThreatHuntAgent, AgentContext, AgentResponse, Perspective
from app.agents.providers_v2 import check_all_nodes
from app.agents.registry import registry
from app.services.sans_rag import sans_rag
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/agent", tags=["agent"])
# Global agent instance
_agent: ThreatHuntAgent | None = None
def get_agent() -> ThreatHuntAgent:
global _agent
if _agent is None:
_agent = ThreatHuntAgent()
return _agent
# ── Request / Response models ─────────────────────────────────────────
class AssistRequest(BaseModel):
query: str = Field(..., max_length=4000, description="Analyst question")
dataset_name: str | None = None
artifact_type: str | None = None
host_identifier: str | None = None
data_summary: str | None = None
conversation_history: list[dict] | None = None
active_hypotheses: list[str] | None = None
annotations_summary: str | None = None
enrichment_summary: str | None = None
mode: str = Field(default="quick", description="quick | deep | debate")
model_override: str | None = None
conversation_id: str | None = Field(None, description="Persist messages to this conversation")
hunt_id: str | None = None
class AssistResponseModel(BaseModel):
guidance: str
confidence: float
suggested_pivots: list[str]
suggested_filters: list[str]
caveats: str | None = None
reasoning: str | None = None
sans_references: list[str] = []
model_used: str = ""
node_used: str = ""
latency_ms: int = 0
perspectives: list[dict] | None = None
conversation_id: str | None = None
# ── Routes ────────────────────────────────────────────────────────────
@router.post(
"/assist",
response_model=AssistResponseModel,
summary="Get analyst-assist guidance",
description="Request guidance with auto-routed model selection. "
"Supports quick (fast), deep (70B), and debate (multi-model) modes.",
)
async def agent_assist(
request: AssistRequest,
db: AsyncSession = Depends(get_db),
) -> AssistResponseModel:
try:
agent = get_agent()
context = AgentContext(
query=request.query,
dataset_name=request.dataset_name,
artifact_type=request.artifact_type,
host_identifier=request.host_identifier,
data_summary=request.data_summary,
conversation_history=request.conversation_history or [],
active_hypotheses=request.active_hypotheses or [],
annotations_summary=request.annotations_summary,
enrichment_summary=request.enrichment_summary,
mode=request.mode,
model_override=request.model_override,
)
response = await agent.assist(context)
# Persist conversation
conv_id = request.conversation_id
if conv_id or request.hunt_id:
conv_id = await _persist_conversation(
db, conv_id, request, response
)
return AssistResponseModel(
guidance=response.guidance,
confidence=response.confidence,
suggested_pivots=response.suggested_pivots,
suggested_filters=response.suggested_filters,
caveats=response.caveats,
reasoning=response.reasoning,
sans_references=response.sans_references,
model_used=response.model_used,
node_used=response.node_used,
latency_ms=response.latency_ms,
perspectives=[
{
"role": p.role,
"content": p.content,
"model_used": p.model_used,
"node_used": p.node_used,
"latency_ms": p.latency_ms,
}
for p in response.perspectives
] if response.perspectives else None,
conversation_id=conv_id,
)
except Exception as e:
logger.exception(f"Agent error: {e}")
raise HTTPException(status_code=500, detail=f"Agent error: {str(e)}")
@router.post(
"/assist/stream",
summary="Stream agent response",
description="Stream tokens via SSE for real-time display.",
)
async def agent_assist_stream(request: AssistRequest):
agent = get_agent()
context = AgentContext(
query=request.query,
dataset_name=request.dataset_name,
artifact_type=request.artifact_type,
host_identifier=request.host_identifier,
data_summary=request.data_summary,
conversation_history=request.conversation_history or [],
mode="quick", # streaming only supports quick mode
)
async def _stream():
async for token in agent.assist_stream(context):
yield f"data: {json.dumps({'token': token})}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(
_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.get(
"/health",
summary="Check agent and node health",
description="Returns availability of all LLM nodes and the cluster.",
)
async def agent_health() -> dict:
nodes = await check_all_nodes()
rag_health = await sans_rag.health_check()
return {
"status": "healthy",
"nodes": nodes,
"rag": rag_health,
"default_models": {
"fast": settings.DEFAULT_FAST_MODEL,
"heavy": settings.DEFAULT_HEAVY_MODEL,
"code": settings.DEFAULT_CODE_MODEL,
"vision": settings.DEFAULT_VISION_MODEL,
"embedding": settings.DEFAULT_EMBEDDING_MODEL,
},
"config": {
"max_tokens": settings.AGENT_MAX_TOKENS,
"temperature": settings.AGENT_TEMPERATURE,
},
}
@router.get(
"/models",
summary="List all available models",
description="Returns the full model registry with capabilities and node assignments.",
)
async def list_models():
return {
"models": registry.to_dict(),
"total": len(registry.models),
}
# ── Conversation persistence ──────────────────────────────────────────
async def _persist_conversation(
db: AsyncSession,
conversation_id: str | None,
request: AssistRequest,
response: AgentResponse,
) -> str:
"""Save user message and agent response to the database."""
if conversation_id:
# Find existing conversation
from sqlalchemy import select
result = await db.execute(
select(Conversation).where(Conversation.id == conversation_id)
)
conv = result.scalar_one_or_none()
if not conv:
conv = Conversation(id=conversation_id, hunt_id=request.hunt_id)
db.add(conv)
else:
conv = Conversation(
title=request.query[:100],
hunt_id=request.hunt_id,
)
db.add(conv)
await db.flush()
# User message
user_msg = Message(
conversation_id=conv.id,
role="user",
content=request.query,
)
db.add(user_msg)
# Agent message
agent_msg = Message(
conversation_id=conv.id,
role="agent",
content=response.guidance,
model_used=response.model_used,
node_used=response.node_used,
latency_ms=response.latency_ms,
response_meta={
"confidence": response.confidence,
"pivots": response.suggested_pivots,
"filters": response.suggested_filters,
"sans_refs": response.sans_references,
},
)
db.add(agent_msg)
await db.flush()
return conv.id

View File

@@ -0,0 +1,402 @@
"""Analysis API routes - triage, host profiles, reports, IOC extraction,
host grouping, anomaly detection, data query (SSE), and job management."""
from __future__ import annotations
import logging
from typing import Optional
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_db
from app.db.models import HostProfile, HuntReport, TriageResult
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/analysis", tags=["analysis"])
# --- Response models ---
class TriageResultResponse(BaseModel):
id: str
dataset_id: str
row_start: int
row_end: int
risk_score: float
verdict: str
findings: list | None = None
suspicious_indicators: list | None = None
mitre_techniques: list | None = None
model_used: str | None = None
node_used: str | None = None
class Config:
from_attributes = True
class HostProfileResponse(BaseModel):
id: str
hunt_id: str
hostname: str
fqdn: str | None = None
risk_score: float
risk_level: str
artifact_summary: dict | None = None
timeline_summary: str | None = None
suspicious_findings: list | None = None
mitre_techniques: list | None = None
llm_analysis: str | None = None
model_used: str | None = None
class Config:
from_attributes = True
class HuntReportResponse(BaseModel):
id: str
hunt_id: str
status: str
exec_summary: str | None = None
full_report: str | None = None
findings: list | None = None
recommendations: list | None = None
mitre_mapping: dict | None = None
ioc_table: list | None = None
host_risk_summary: list | None = None
models_used: list | None = None
generation_time_ms: int | None = None
class Config:
from_attributes = True
class QueryRequest(BaseModel):
question: str
mode: str = "quick" # quick or deep
# --- Triage endpoints ---
@router.get("/triage/{dataset_id}", response_model=list[TriageResultResponse])
async def get_triage_results(
dataset_id: str,
min_risk: float = Query(0.0, ge=0.0, le=10.0),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(TriageResult)
.where(TriageResult.dataset_id == dataset_id)
.where(TriageResult.risk_score >= min_risk)
.order_by(TriageResult.risk_score.desc())
)
return result.scalars().all()
@router.post("/triage/{dataset_id}")
async def trigger_triage(
dataset_id: str,
background_tasks: BackgroundTasks,
):
async def _run():
from app.services.triage import triage_dataset
await triage_dataset(dataset_id)
background_tasks.add_task(_run)
return {"status": "triage_started", "dataset_id": dataset_id}
# --- Host profile endpoints ---
@router.get("/profiles/{hunt_id}", response_model=list[HostProfileResponse])
async def get_host_profiles(
hunt_id: str,
min_risk: float = Query(0.0, ge=0.0, le=10.0),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(HostProfile)
.where(HostProfile.hunt_id == hunt_id)
.where(HostProfile.risk_score >= min_risk)
.order_by(HostProfile.risk_score.desc())
)
return result.scalars().all()
@router.post("/profiles/{hunt_id}")
async def trigger_all_profiles(
hunt_id: str,
background_tasks: BackgroundTasks,
):
async def _run():
from app.services.host_profiler import profile_all_hosts
await profile_all_hosts(hunt_id)
background_tasks.add_task(_run)
return {"status": "profiling_started", "hunt_id": hunt_id}
@router.post("/profiles/{hunt_id}/{hostname}")
async def trigger_single_profile(
hunt_id: str,
hostname: str,
background_tasks: BackgroundTasks,
):
async def _run():
from app.services.host_profiler import profile_host
await profile_host(hunt_id, hostname)
background_tasks.add_task(_run)
return {"status": "profiling_started", "hunt_id": hunt_id, "hostname": hostname}
# --- Report endpoints ---
@router.get("/reports/{hunt_id}", response_model=list[HuntReportResponse])
async def list_reports(
hunt_id: str,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(HuntReport)
.where(HuntReport.hunt_id == hunt_id)
.order_by(HuntReport.created_at.desc())
)
return result.scalars().all()
@router.get("/reports/{hunt_id}/{report_id}", response_model=HuntReportResponse)
async def get_report(
hunt_id: str,
report_id: str,
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(HuntReport)
.where(HuntReport.id == report_id)
.where(HuntReport.hunt_id == hunt_id)
)
report = result.scalar_one_or_none()
if not report:
raise HTTPException(status_code=404, detail="Report not found")
return report
@router.post("/reports/{hunt_id}/generate")
async def trigger_report(
hunt_id: str,
background_tasks: BackgroundTasks,
):
async def _run():
from app.services.report_generator import generate_report
await generate_report(hunt_id)
background_tasks.add_task(_run)
return {"status": "report_generation_started", "hunt_id": hunt_id}
# --- IOC extraction endpoints ---
@router.get("/iocs/{dataset_id}")
async def extract_iocs(
dataset_id: str,
max_rows: int = Query(5000, ge=1, le=50000),
db: AsyncSession = Depends(get_db),
):
"""Extract IOCs (IPs, domains, hashes, etc.) from dataset rows."""
from app.services.ioc_extractor import extract_iocs_from_dataset
iocs = await extract_iocs_from_dataset(dataset_id, db, max_rows=max_rows)
total = sum(len(v) for v in iocs.values())
return {"dataset_id": dataset_id, "iocs": iocs, "total": total}
# --- Host grouping endpoints ---
@router.get("/hosts/{hunt_id}")
async def get_host_groups(
hunt_id: str,
db: AsyncSession = Depends(get_db),
):
"""Group data by hostname across all datasets in a hunt."""
from app.services.ioc_extractor import extract_host_groups
groups = await extract_host_groups(hunt_id, db)
return {"hunt_id": hunt_id, "hosts": groups}
# --- Anomaly detection endpoints ---
@router.get("/anomalies/{dataset_id}")
async def get_anomalies(
dataset_id: str,
outliers_only: bool = Query(False),
db: AsyncSession = Depends(get_db),
):
"""Get anomaly detection results for a dataset."""
from app.db.models import AnomalyResult
stmt = select(AnomalyResult).where(AnomalyResult.dataset_id == dataset_id)
if outliers_only:
stmt = stmt.where(AnomalyResult.is_outlier == True)
stmt = stmt.order_by(AnomalyResult.anomaly_score.desc())
result = await db.execute(stmt)
rows = result.scalars().all()
return [
{
"id": r.id,
"dataset_id": r.dataset_id,
"row_id": r.row_id,
"anomaly_score": r.anomaly_score,
"distance_from_centroid": r.distance_from_centroid,
"cluster_id": r.cluster_id,
"is_outlier": r.is_outlier,
"explanation": r.explanation,
}
for r in rows
]
@router.post("/anomalies/{dataset_id}")
async def trigger_anomaly_detection(
dataset_id: str,
k: int = Query(3, ge=2, le=20),
threshold: float = Query(0.35, ge=0.1, le=0.9),
background_tasks: BackgroundTasks = None,
):
"""Trigger embedding-based anomaly detection on a dataset."""
async def _run():
from app.services.anomaly_detector import detect_anomalies
await detect_anomalies(dataset_id, k=k, outlier_threshold=threshold)
if background_tasks:
background_tasks.add_task(_run)
return {"status": "anomaly_detection_started", "dataset_id": dataset_id}
else:
from app.services.anomaly_detector import detect_anomalies
results = await detect_anomalies(dataset_id, k=k, outlier_threshold=threshold)
return {"status": "complete", "dataset_id": dataset_id, "count": len(results)}
# --- Natural language data query (SSE streaming) ---
@router.post("/query/{dataset_id}")
async def query_dataset_endpoint(
dataset_id: str,
body: QueryRequest,
):
"""Ask a natural language question about a dataset.
Returns an SSE stream with token-by-token LLM response.
Event types: status, metadata, token, error, done
"""
from app.services.data_query import query_dataset_stream
return StreamingResponse(
query_dataset_stream(dataset_id, body.question, body.mode),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.post("/query/{dataset_id}/sync")
async def query_dataset_sync(
dataset_id: str,
body: QueryRequest,
):
"""Non-streaming version of data query."""
from app.services.data_query import query_dataset
try:
answer = await query_dataset(dataset_id, body.question, body.mode)
return {"dataset_id": dataset_id, "question": body.question, "answer": answer, "mode": body.mode}
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Query failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
# --- Job queue endpoints ---
@router.get("/jobs")
async def list_jobs(
status: str | None = Query(None),
job_type: str | None = Query(None),
limit: int = Query(50, ge=1, le=200),
):
"""List all tracked jobs."""
from app.services.job_queue import job_queue, JobStatus, JobType
s = JobStatus(status) if status else None
t = JobType(job_type) if job_type else None
jobs = job_queue.list_jobs(status=s, job_type=t, limit=limit)
stats = job_queue.get_stats()
return {"jobs": jobs, "stats": stats}
@router.get("/jobs/{job_id}")
async def get_job(job_id: str):
"""Get status of a specific job."""
from app.services.job_queue import job_queue
job = job_queue.get_job(job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
return job.to_dict()
@router.delete("/jobs/{job_id}")
async def cancel_job(job_id: str):
"""Cancel a running or queued job."""
from app.services.job_queue import job_queue
if job_queue.cancel_job(job_id):
return {"status": "cancelled", "job_id": job_id}
raise HTTPException(status_code=400, detail="Job cannot be cancelled (already complete or not found)")
@router.post("/jobs/submit/{job_type}")
async def submit_job(
job_type: str,
params: dict = {},
):
"""Submit a new job to the queue.
Job types: triage, host_profile, report, anomaly, query
Params vary by type (e.g., dataset_id, hunt_id, question, mode).
"""
from app.services.job_queue import job_queue, JobType
try:
jt = JobType(job_type)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid job_type: {job_type}. Valid: {[t.value for t in JobType]}",
)
job = job_queue.submit(jt, **params)
return {"job_id": job.id, "status": job.status.value, "job_type": job_type}
# --- Load balancer status ---
@router.get("/lb/status")
async def lb_status():
"""Get load balancer status for both nodes."""
from app.services.load_balancer import lb
return lb.get_status()
@router.post("/lb/check")
async def lb_health_check():
"""Force a health check of both nodes."""
from app.services.load_balancer import lb
await lb.check_health()
return lb.get_status()

View File

@@ -0,0 +1,311 @@
"""API routes for annotations and hypotheses."""
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_db
from app.db.models import Annotation, Hypothesis
logger = logging.getLogger(__name__)
router = APIRouter(tags=["annotations"])
# ── Annotation models ─────────────────────────────────────────────────
class AnnotationCreate(BaseModel):
row_id: int | None = None
dataset_id: str | None = None
text: str = Field(..., max_length=2000)
severity: str = Field(default="info") # info|low|medium|high|critical
tag: str | None = None # suspicious|benign|needs-review
highlight_color: str | None = None
class AnnotationUpdate(BaseModel):
text: str | None = None
severity: str | None = None
tag: str | None = None
highlight_color: str | None = None
class AnnotationResponse(BaseModel):
id: str
row_id: int | None
dataset_id: str | None
author_id: str | None
text: str
severity: str
tag: str | None
highlight_color: str | None
created_at: str
updated_at: str
class AnnotationListResponse(BaseModel):
annotations: list[AnnotationResponse]
total: int
# ── Hypothesis models ─────────────────────────────────────────────────
class HypothesisCreate(BaseModel):
hunt_id: str | None = None
title: str = Field(..., max_length=256)
description: str | None = None
mitre_technique: str | None = None
status: str = Field(default="draft")
class HypothesisUpdate(BaseModel):
title: str | None = None
description: str | None = None
mitre_technique: str | None = None
status: str | None = None # draft|active|confirmed|rejected
evidence_row_ids: list[int] | None = None
evidence_notes: str | None = None
class HypothesisResponse(BaseModel):
id: str
hunt_id: str | None
title: str
description: str | None
mitre_technique: str | None
status: str
evidence_row_ids: list | None
evidence_notes: str | None
created_at: str
updated_at: str
class HypothesisListResponse(BaseModel):
hypotheses: list[HypothesisResponse]
total: int
# ── Annotation routes ─────────────────────────────────────────────────
ann_router = APIRouter(prefix="/api/annotations")
@ann_router.post("", response_model=AnnotationResponse, summary="Create annotation")
async def create_annotation(body: AnnotationCreate, db: AsyncSession = Depends(get_db)):
ann = Annotation(
row_id=body.row_id,
dataset_id=body.dataset_id,
text=body.text,
severity=body.severity,
tag=body.tag,
highlight_color=body.highlight_color,
)
db.add(ann)
await db.flush()
return AnnotationResponse(
id=ann.id, row_id=ann.row_id, dataset_id=ann.dataset_id,
author_id=ann.author_id, text=ann.text, severity=ann.severity,
tag=ann.tag, highlight_color=ann.highlight_color,
created_at=ann.created_at.isoformat(), updated_at=ann.updated_at.isoformat(),
)
@ann_router.get("", response_model=AnnotationListResponse, summary="List annotations")
async def list_annotations(
dataset_id: str | None = Query(None),
row_id: int | None = Query(None),
tag: str | None = Query(None),
severity: str | None = Query(None),
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
):
stmt = select(Annotation).order_by(Annotation.created_at.desc())
if dataset_id:
stmt = stmt.where(Annotation.dataset_id == dataset_id)
if row_id:
stmt = stmt.where(Annotation.row_id == row_id)
if tag:
stmt = stmt.where(Annotation.tag == tag)
if severity:
stmt = stmt.where(Annotation.severity == severity)
stmt = stmt.limit(limit).offset(offset)
result = await db.execute(stmt)
annotations = result.scalars().all()
count_stmt = select(func.count(Annotation.id))
if dataset_id:
count_stmt = count_stmt.where(Annotation.dataset_id == dataset_id)
total = (await db.execute(count_stmt)).scalar_one()
return AnnotationListResponse(
annotations=[
AnnotationResponse(
id=a.id, row_id=a.row_id, dataset_id=a.dataset_id,
author_id=a.author_id, text=a.text, severity=a.severity,
tag=a.tag, highlight_color=a.highlight_color,
created_at=a.created_at.isoformat(), updated_at=a.updated_at.isoformat(),
)
for a in annotations
],
total=total,
)
@ann_router.put("/{annotation_id}", response_model=AnnotationResponse, summary="Update annotation")
async def update_annotation(
annotation_id: str, body: AnnotationUpdate, db: AsyncSession = Depends(get_db)
):
result = await db.execute(select(Annotation).where(Annotation.id == annotation_id))
ann = result.scalar_one_or_none()
if not ann:
raise HTTPException(status_code=404, detail="Annotation not found")
if body.text is not None:
ann.text = body.text
if body.severity is not None:
ann.severity = body.severity
if body.tag is not None:
ann.tag = body.tag
if body.highlight_color is not None:
ann.highlight_color = body.highlight_color
await db.flush()
return AnnotationResponse(
id=ann.id, row_id=ann.row_id, dataset_id=ann.dataset_id,
author_id=ann.author_id, text=ann.text, severity=ann.severity,
tag=ann.tag, highlight_color=ann.highlight_color,
created_at=ann.created_at.isoformat(), updated_at=ann.updated_at.isoformat(),
)
@ann_router.delete("/{annotation_id}", summary="Delete annotation")
async def delete_annotation(annotation_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Annotation).where(Annotation.id == annotation_id))
ann = result.scalar_one_or_none()
if not ann:
raise HTTPException(status_code=404, detail="Annotation not found")
await db.delete(ann)
return {"message": "Annotation deleted", "id": annotation_id}
# ── Hypothesis routes ─────────────────────────────────────────────────
hyp_router = APIRouter(prefix="/api/hypotheses")
@hyp_router.post("", response_model=HypothesisResponse, summary="Create hypothesis")
async def create_hypothesis(body: HypothesisCreate, db: AsyncSession = Depends(get_db)):
hyp = Hypothesis(
hunt_id=body.hunt_id,
title=body.title,
description=body.description,
mitre_technique=body.mitre_technique,
status=body.status,
)
db.add(hyp)
await db.flush()
return HypothesisResponse(
id=hyp.id, hunt_id=hyp.hunt_id, title=hyp.title,
description=hyp.description, mitre_technique=hyp.mitre_technique,
status=hyp.status, evidence_row_ids=hyp.evidence_row_ids,
evidence_notes=hyp.evidence_notes,
created_at=hyp.created_at.isoformat(), updated_at=hyp.updated_at.isoformat(),
)
@hyp_router.get("", response_model=HypothesisListResponse, summary="List hypotheses")
async def list_hypotheses(
hunt_id: str | None = Query(None),
status: str | None = Query(None),
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
):
stmt = select(Hypothesis).order_by(Hypothesis.updated_at.desc())
if hunt_id:
stmt = stmt.where(Hypothesis.hunt_id == hunt_id)
if status:
stmt = stmt.where(Hypothesis.status == status)
stmt = stmt.limit(limit).offset(offset)
result = await db.execute(stmt)
hyps = result.scalars().all()
count_stmt = select(func.count(Hypothesis.id))
if hunt_id:
count_stmt = count_stmt.where(Hypothesis.hunt_id == hunt_id)
total = (await db.execute(count_stmt)).scalar_one()
return HypothesisListResponse(
hypotheses=[
HypothesisResponse(
id=h.id, hunt_id=h.hunt_id, title=h.title,
description=h.description, mitre_technique=h.mitre_technique,
status=h.status, evidence_row_ids=h.evidence_row_ids,
evidence_notes=h.evidence_notes,
created_at=h.created_at.isoformat(), updated_at=h.updated_at.isoformat(),
)
for h in hyps
],
total=total,
)
@hyp_router.get("/{hypothesis_id}", response_model=HypothesisResponse, summary="Get hypothesis")
async def get_hypothesis(hypothesis_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Hypothesis).where(Hypothesis.id == hypothesis_id))
hyp = result.scalar_one_or_none()
if not hyp:
raise HTTPException(status_code=404, detail="Hypothesis not found")
return HypothesisResponse(
id=hyp.id, hunt_id=hyp.hunt_id, title=hyp.title,
description=hyp.description, mitre_technique=hyp.mitre_technique,
status=hyp.status, evidence_row_ids=hyp.evidence_row_ids,
evidence_notes=hyp.evidence_notes,
created_at=hyp.created_at.isoformat(), updated_at=hyp.updated_at.isoformat(),
)
@hyp_router.put("/{hypothesis_id}", response_model=HypothesisResponse, summary="Update hypothesis")
async def update_hypothesis(
hypothesis_id: str, body: HypothesisUpdate, db: AsyncSession = Depends(get_db)
):
result = await db.execute(select(Hypothesis).where(Hypothesis.id == hypothesis_id))
hyp = result.scalar_one_or_none()
if not hyp:
raise HTTPException(status_code=404, detail="Hypothesis not found")
if body.title is not None:
hyp.title = body.title
if body.description is not None:
hyp.description = body.description
if body.mitre_technique is not None:
hyp.mitre_technique = body.mitre_technique
if body.status is not None:
hyp.status = body.status
if body.evidence_row_ids is not None:
hyp.evidence_row_ids = body.evidence_row_ids
if body.evidence_notes is not None:
hyp.evidence_notes = body.evidence_notes
await db.flush()
return HypothesisResponse(
id=hyp.id, hunt_id=hyp.hunt_id, title=hyp.title,
description=hyp.description, mitre_technique=hyp.mitre_technique,
status=hyp.status, evidence_row_ids=hyp.evidence_row_ids,
evidence_notes=hyp.evidence_notes,
created_at=hyp.created_at.isoformat(), updated_at=hyp.updated_at.isoformat(),
)
@hyp_router.delete("/{hypothesis_id}", summary="Delete hypothesis")
async def delete_hypothesis(hypothesis_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Hypothesis).where(Hypothesis.id == hypothesis_id))
hyp = result.scalar_one_or_none()
if not hyp:
raise HTTPException(status_code=404, detail="Hypothesis not found")
await db.delete(hyp)
return {"message": "Hypothesis deleted", "id": hypothesis_id}

View File

@@ -1,75 +0,0 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session
from datetime import datetime
from app.core.database import get_db
from app.core.deps import get_current_active_user, require_role
from app.models.user import User
from app.models.audit_log import AuditLog
from app.schemas.audit import AuditLogRead
router = APIRouter()
@router.get("/", response_model=List[AuditLogRead])
async def list_audit_logs(
skip: int = 0,
limit: int = 100,
action: Optional[str] = Query(None, description="Filter by action type"),
resource_type: Optional[str] = Query(None, description="Filter by resource type"),
start_date: Optional[datetime] = Query(None, description="Filter from date"),
end_date: Optional[datetime] = Query(None, description="Filter to date"),
current_user: User = Depends(require_role(["admin"])),
db: Session = Depends(get_db)
):
"""
List audit logs (admin only, scoped to tenant)
Provides a complete audit trail of actions within the tenant.
"""
# Base query scoped to tenant
query = db.query(AuditLog).filter(AuditLog.tenant_id == current_user.tenant_id)
# Apply filters
if action:
query = query.filter(AuditLog.action == action)
if resource_type:
query = query.filter(AuditLog.resource_type == resource_type)
if start_date:
query = query.filter(AuditLog.created_at >= start_date)
if end_date:
query = query.filter(AuditLog.created_at <= end_date)
# Order by most recent first
query = query.order_by(AuditLog.created_at.desc())
# Paginate
logs = query.offset(skip).limit(limit).all()
return logs
@router.get("/{log_id}", response_model=AuditLogRead)
async def get_audit_log(
log_id: int,
current_user: User = Depends(require_role(["admin"])),
db: Session = Depends(get_db)
):
"""
Get a specific audit log entry (admin only)
"""
from fastapi import HTTPException, status
log = db.query(AuditLog).filter(
AuditLog.id == log_id,
AuditLog.tenant_id == current_user.tenant_id
).first()
if not log:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Audit log not found"
)
return log

View File

@@ -1,432 +1,197 @@
"""API routes for authentication — register, login, refresh, profile."""
import logging
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm from pydantic import BaseModel, Field, EmailStr
from sqlalchemy.orm import Session from sqlalchemy import select
from datetime import datetime, timezone, timedelta from sqlalchemy.ext.asyncio import AsyncSession
import io
import qrcode
from app.core.database import get_db from app.db import get_db
from app.core.security import ( from app.db.models import User
verify_password, get_password_hash, create_access_token, from app.services.auth import (
create_refresh_token, create_reset_token, generate_totp_secret, hash_password,
verify_totp, get_totp_uri verify_password,
create_token_pair,
decode_token,
get_current_user,
TokenPair,
) )
from app.core.deps import get_current_active_user
from app.models.user import User logger = logging.getLogger(__name__)
from app.models.tenant import Tenant
from app.models.refresh_token import RefreshToken router = APIRouter(prefix="/api/auth", tags=["auth"])
from app.models.password_reset_token import PasswordResetToken
from app.schemas.auth import (
Token, UserLogin, UserRegister, RefreshTokenRequest, # ── Request / Response models ─────────────────────────────────────────
PasswordResetRequest, PasswordResetConfirm,
TwoFactorSetup, TwoFactorVerify
class RegisterRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=64)
email: str = Field(..., max_length=256)
password: str = Field(..., min_length=8, max_length=128)
display_name: str | None = None
class LoginRequest(BaseModel):
username: str
password: str
class RefreshRequest(BaseModel):
refresh_token: str
class UserResponse(BaseModel):
id: str
username: str
email: str
display_name: str | None
role: str
is_active: bool
created_at: str
class AuthResponse(BaseModel):
user: UserResponse
tokens: TokenPair
# ── Routes ────────────────────────────────────────────────────────────
@router.post(
"/register",
response_model=AuthResponse,
status_code=status.HTTP_201_CREATED,
summary="Register a new user",
) )
from app.schemas.user import UserRead, UserUpdate async def register(body: RegisterRequest, db: AsyncSession = Depends(get_db)):
# Check for existing username
router = APIRouter() result = await db.execute(select(User).where(User.username == body.username))
if result.scalar_one_or_none():
@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def register(
user_data: UserRegister,
db: Session = Depends(get_db)
):
"""
Register a new user
Creates a new user with hashed password. If tenant_id is not provided,
a default tenant is created or used.
"""
# Check if username already exists
existing_user = db.query(User).filter(User.username == user_data.username).first()
if existing_user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_409_CONFLICT,
detail="Username already registered" detail="Username already taken",
) )
# Handle tenant_id # Check for existing email
tenant_id = user_data.tenant_id result = await db.execute(select(User).where(User.email == body.email))
if tenant_id is None: if result.scalar_one_or_none():
# Create or get default tenant raise HTTPException(
default_tenant = db.query(Tenant).filter(Tenant.name == "default").first() status_code=status.HTTP_409_CONFLICT,
if not default_tenant: detail="Email already registered",
default_tenant = Tenant(name="default", description="Default tenant") )
db.add(default_tenant)
db.commit() user = User(
db.refresh(default_tenant) username=body.username,
tenant_id = default_tenant.id email=body.email,
else: password_hash=hash_password(body.password),
# Verify tenant exists display_name=body.display_name or body.username,
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first() role="analyst", # Default role
if not tenant: )
raise HTTPException( db.add(user)
status_code=status.HTTP_404_NOT_FOUND, await db.flush()
detail="Tenant not found"
) tokens = create_token_pair(user.id, user.role)
# Create new user with hashed password logger.info(f"New user registered: {user.username} ({user.id})")
hashed_password = get_password_hash(user_data.password)
new_user = User( return AuthResponse(
username=user_data.username, user=UserResponse(
password_hash=hashed_password, id=user.id,
role=user_data.role, username=user.username,
tenant_id=tenant_id email=user.email,
display_name=user.display_name,
role=user.role,
is_active=user.is_active,
created_at=user.created_at.isoformat(),
),
tokens=tokens,
) )
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
@router.post("/login", response_model=Token) @router.post(
async def login( "/login",
form_data: OAuth2PasswordRequestForm = Depends(), response_model=AuthResponse,
db: Session = Depends(get_db) summary="Login with username and password",
): )
""" async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
Authenticate user and return JWT token result = await db.execute(select(User).where(User.username == body.username))
user = result.scalar_one_or_none()
Uses OAuth2 password flow for compatibility with OpenAPI docs.
""" if not user or not user.password_hash:
# Find user by username
user = db.query(User).filter(User.username == form_data.username).first()
if not user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password", detail="Invalid username or password",
headers={"WWW-Authenticate": "Bearer"},
) )
# Verify password if not verify_password(body.password, user.password_hash):
if not verify_password(form_data.password, user.password_hash):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password", detail="Invalid username or password",
headers={"WWW-Authenticate": "Bearer"},
) )
# Check if user is active
if not user.is_active: if not user.is_active:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user" detail="Account is disabled",
) )
# Check 2FA if enabled (TOTP code should be in scopes for OAuth2) tokens = create_token_pair(user.id, user.role)
if user.totp_enabled:
# For OAuth2 password flow, we'll check totp in scopes return AuthResponse(
totp_code = form_data.scopes[0] if form_data.scopes else None user=UserResponse(
if not totp_code or not verify_totp(user.totp_secret, totp_code): id=user.id,
raise HTTPException( username=user.username,
status_code=status.HTTP_401_UNAUTHORIZED, email=user.email,
detail="Invalid 2FA code", display_name=user.display_name,
headers={"WWW-Authenticate": "Bearer"}, role=user.role,
) is_active=user.is_active,
created_at=user.created_at.isoformat(),
# Create access token ),
access_token = create_access_token( tokens=tokens,
data={
"sub": user.id,
"tenant_id": user.tenant_id,
"role": user.role
}
) )
# Create refresh token
refresh_token_str = create_refresh_token()
refresh_token_obj = RefreshToken(
token=refresh_token_str,
user_id=user.id,
expires_at=datetime.now(timezone.utc) + timedelta(days=30)
)
db.add(refresh_token_obj)
db.commit()
return {
"access_token": access_token,
"refresh_token": refresh_token_str,
"token_type": "bearer"
}
@router.get("/me", response_model=UserRead) @router.post(
async def get_current_user_profile( "/refresh",
current_user: User = Depends(get_current_active_user) response_model=TokenPair,
): summary="Refresh access token",
""" )
Get current user profile async def refresh_token(body: RefreshRequest, db: AsyncSession = Depends(get_db)):
token_data = decode_token(body.refresh_token)
Returns the profile of the authenticated user.
"""
return current_user
if token_data.type != "refresh":
@router.put("/me", response_model=UserRead)
async def update_current_user_profile(
user_update: UserUpdate,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Update current user profile
Allows users to update their own profile information.
"""
# Update username if provided
if user_update.username is not None:
# Check if new username is already taken
existing_user = db.query(User).filter(
User.username == user_update.username,
User.id != current_user.id
).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already taken"
)
current_user.username = user_update.username
# Update password if provided
if user_update.password is not None:
current_user.password_hash = get_password_hash(user_update.password)
# Users cannot change their own role through this endpoint
# (admin users should use the admin endpoints in /users)
db.commit()
db.refresh(current_user)
return current_user
@router.post("/refresh", response_model=Token)
async def refresh_access_token(
refresh_request: RefreshTokenRequest,
db: Session = Depends(get_db)
):
"""
Refresh access token using refresh token
Provides a new access token without requiring login.
"""
# Find refresh token
refresh_token = db.query(RefreshToken).filter(
RefreshToken.token == refresh_request.refresh_token,
RefreshToken.is_revoked == False
).first()
if not refresh_token:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid refresh token" detail="Invalid token type — use refresh token",
) )
# Check if expired result = await db.execute(select(User).where(User.id == token_data.sub))
if refresh_token.expires_at < datetime.now(timezone.utc): user = result.scalar_one_or_none()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Refresh token expired"
)
# Get user
user = db.query(User).filter(User.id == refresh_token.user_id).first()
if not user or not user.is_active: if not user or not user.is_active:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found or inactive" detail="Invalid user",
) )
# Create new access token return create_token_pair(user.id, user.role)
access_token = create_access_token(
data={
"sub": user.id, @router.get(
"tenant_id": user.tenant_id, "/me",
"role": user.role response_model=UserResponse,
} summary="Get current user profile",
)
async def get_profile(user: User = Depends(get_current_user)):
return UserResponse(
id=user.id,
username=user.username,
email=user.email,
display_name=user.display_name,
role=user.role,
is_active=user.is_active,
created_at=user.created_at.isoformat() if hasattr(user.created_at, 'isoformat') else str(user.created_at),
) )
return {
"access_token": access_token,
"refresh_token": refresh_request.refresh_token,
"token_type": "bearer"
}
@router.post("/2fa/setup", response_model=TwoFactorSetup)
async def setup_two_factor(
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Set up two-factor authentication
Generates a TOTP secret and QR code URI for the user.
"""
if current_user.totp_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="2FA is already enabled"
)
# Generate secret
secret = generate_totp_secret()
current_user.totp_secret = secret
db.commit()
# Get QR code URI
qr_uri = get_totp_uri(secret, current_user.username)
return {
"secret": secret,
"qr_code_uri": qr_uri
}
@router.post("/2fa/verify")
async def verify_two_factor(
verify_request: TwoFactorVerify,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Verify and enable two-factor authentication
User must provide a valid TOTP code to enable 2FA.
"""
if current_user.totp_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="2FA is already enabled"
)
if not current_user.totp_secret:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="2FA setup not initiated. Call /2fa/setup first."
)
# Verify code
if not verify_totp(current_user.totp_secret, verify_request.code):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid 2FA code"
)
# Enable 2FA
current_user.totp_enabled = True
db.commit()
return {"message": "2FA enabled successfully"}
@router.post("/2fa/disable")
async def disable_two_factor(
verify_request: TwoFactorVerify,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Disable two-factor authentication
User must provide a valid TOTP code to disable 2FA.
"""
if not current_user.totp_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="2FA is not enabled"
)
# Verify code
if not verify_totp(current_user.totp_secret, verify_request.code):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid 2FA code"
)
# Disable 2FA
current_user.totp_enabled = False
current_user.totp_secret = None
db.commit()
return {"message": "2FA disabled successfully"}
@router.post("/password-reset/request")
async def request_password_reset(
reset_request: PasswordResetRequest,
db: Session = Depends(get_db)
):
"""
Request a password reset
Sends a password reset email to the user (mock implementation).
"""
# Find user by email
user = db.query(User).filter(User.email == reset_request.email).first()
# Don't reveal if email exists or not (security best practice)
# Always return success even if email doesn't exist
if user:
# Create reset token
reset_token = create_reset_token()
reset_token_obj = PasswordResetToken(
token=reset_token,
user_id=user.id,
expires_at=datetime.now(timezone.utc) + timedelta(hours=1)
)
db.add(reset_token_obj)
db.commit()
# TODO: Send email with reset link
# For now, we'll just log it (in production, use an email service)
print(f"Password reset token for {user.email}: {reset_token}")
return {"message": "If the email exists, a password reset link has been sent"}
@router.post("/password-reset/confirm")
async def confirm_password_reset(
reset_confirm: PasswordResetConfirm,
db: Session = Depends(get_db)
):
"""
Confirm password reset with token
Sets a new password for the user.
"""
# Find reset token
reset_token = db.query(PasswordResetToken).filter(
PasswordResetToken.token == reset_confirm.token,
PasswordResetToken.is_used == False
).first()
if not reset_token:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired reset token"
)
# Check if expired
if reset_token.expires_at < datetime.now(timezone.utc):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Reset token expired"
)
# Get user
user = db.query(User).filter(User.id == reset_token.user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Update password
user.password_hash = get_password_hash(reset_confirm.new_password)
reset_token.is_used = True
db.commit()
return {"message": "Password reset successful"}

View File

@@ -0,0 +1,83 @@
"""API routes for cross-hunt correlation analysis."""
import logging
from dataclasses import asdict
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_db
from app.services.correlation import correlation_engine
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/correlation", tags=["correlation"])
class CorrelateRequest(BaseModel):
hunt_ids: list[str] = Field(
...,
min_length=2,
max_length=20,
description="List of hunt IDs to correlate",
)
@router.post(
"/analyze",
summary="Run correlation analysis across hunts",
description="Find shared IOCs, overlapping time windows, common MITRE techniques, "
"and host patterns across the specified hunts.",
)
async def correlate_hunts(
body: CorrelateRequest,
db: AsyncSession = Depends(get_db),
):
result = await correlation_engine.correlate_hunts(body.hunt_ids, db)
return {
"hunt_ids": result.hunt_ids,
"summary": result.summary,
"total_correlations": result.total_correlations,
"ioc_overlaps": [asdict(o) for o in result.ioc_overlaps],
"time_overlaps": [asdict(o) for o in result.time_overlaps],
"technique_overlaps": [asdict(o) for o in result.technique_overlaps],
"host_overlaps": result.host_overlaps,
}
@router.get(
"/all",
summary="Correlate all hunts",
description="Run correlation across all hunts in the system.",
)
async def correlate_all(db: AsyncSession = Depends(get_db)):
result = await correlation_engine.correlate_all(db)
return {
"hunt_ids": result.hunt_ids,
"summary": result.summary,
"total_correlations": result.total_correlations,
"ioc_overlaps": [asdict(o) for o in result.ioc_overlaps[:20]],
"time_overlaps": [asdict(o) for o in result.time_overlaps[:10]],
"technique_overlaps": [asdict(o) for o in result.technique_overlaps[:10]],
"host_overlaps": result.host_overlaps[:10],
}
@router.get(
"/ioc/{ioc_value}",
summary="Find IOC across all hunts",
description="Search for a specific IOC value across all datasets and hunts.",
)
async def find_ioc(
ioc_value: str,
db: AsyncSession = Depends(get_db),
):
occurrences = await correlation_engine.find_ioc_across_hunts(ioc_value, db)
return {
"ioc_value": ioc_value,
"occurrences": occurrences,
"total": len(occurrences),
"unique_hunts": len(set(o["hunt_id"] for o in occurrences if o.get("hunt_id"))),
}

View File

@@ -0,0 +1,295 @@
"""API routes for dataset upload, listing, and management."""
import logging
import os
from pathlib import Path
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.db import get_db
from app.db.repositories.datasets import DatasetRepository
from app.services.csv_parser import parse_csv_bytes, infer_column_types
from app.services.normalizer import (
normalize_columns,
normalize_rows,
detect_ioc_columns,
detect_time_range,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/datasets", tags=["datasets"])
ALLOWED_EXTENSIONS = {".csv", ".tsv", ".txt"}
# ── Response models ───────────────────────────────────────────────────
class DatasetSummary(BaseModel):
id: str
name: str
filename: str
source_tool: str | None = None
row_count: int
column_schema: dict | None = None
normalized_columns: dict | None = None
ioc_columns: dict | None = None
file_size_bytes: int
encoding: str | None = None
delimiter: str | None = None
time_range_start: str | None = None
time_range_end: str | None = None
hunt_id: str | None = None
created_at: str
class DatasetListResponse(BaseModel):
datasets: list[DatasetSummary]
total: int
class RowsResponse(BaseModel):
rows: list[dict]
total: int
offset: int
limit: int
class UploadResponse(BaseModel):
id: str
name: str
row_count: int
columns: list[str]
column_types: dict
normalized_columns: dict
ioc_columns: dict
message: str
# ── Routes ────────────────────────────────────────────────────────────
@router.post(
"/upload",
response_model=UploadResponse,
summary="Upload a CSV dataset",
description="Upload a CSV/TSV file for analysis. The file is parsed, columns normalized, "
"IOCs auto-detected, and rows stored in the database.",
)
async def upload_dataset(
file: UploadFile = File(...),
name: str | None = Query(None, description="Display name for the dataset"),
source_tool: str | None = Query(None, description="Source tool (e.g., velociraptor)"),
hunt_id: str | None = Query(None, description="Hunt ID to associate with"),
db: AsyncSession = Depends(get_db),
):
"""Upload and parse a CSV dataset."""
# Validate file
if not file.filename:
raise HTTPException(status_code=400, detail="No filename provided")
ext = Path(file.filename).suffix.lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"File type '{ext}' not allowed. Accepted: {', '.join(ALLOWED_EXTENSIONS)}",
)
# Read file bytes
raw_bytes = await file.read()
if len(raw_bytes) == 0:
raise HTTPException(status_code=400, detail="File is empty")
if len(raw_bytes) > settings.max_upload_bytes:
raise HTTPException(
status_code=413,
detail=f"File too large. Max size: {settings.MAX_UPLOAD_SIZE_MB} MB",
)
# Parse CSV
try:
rows, metadata = parse_csv_bytes(raw_bytes)
except Exception as e:
logger.error(f"CSV parse error: {e}")
raise HTTPException(
status_code=422,
detail=f"Failed to parse CSV: {str(e)}. Check encoding and format.",
)
if not rows:
raise HTTPException(status_code=422, detail="CSV file contains no data rows")
columns: list[str] = metadata["columns"]
column_types: dict = metadata["column_types"]
# Normalize columns
column_mapping = normalize_columns(columns)
normalized = normalize_rows(rows, column_mapping)
# Detect IOCs
ioc_columns = detect_ioc_columns(columns, column_types, column_mapping)
# Detect time range
time_start, time_end = detect_time_range(rows, column_mapping)
# Store in DB
repo = DatasetRepository(db)
dataset = await repo.create_dataset(
name=name or Path(file.filename).stem,
filename=file.filename,
source_tool=source_tool,
row_count=len(rows),
column_schema=column_types,
normalized_columns=column_mapping,
ioc_columns=ioc_columns,
file_size_bytes=len(raw_bytes),
encoding=metadata["encoding"],
delimiter=metadata["delimiter"],
time_range_start=time_start,
time_range_end=time_end,
hunt_id=hunt_id,
)
await repo.bulk_insert_rows(
dataset_id=dataset.id,
rows=rows,
normalized_rows=normalized,
)
logger.info(
f"Uploaded dataset '{dataset.name}': {len(rows)} rows, "
f"{len(columns)} columns, {len(ioc_columns)} IOC columns detected"
)
return UploadResponse(
id=dataset.id,
name=dataset.name,
row_count=len(rows),
columns=columns,
column_types=column_types,
normalized_columns=column_mapping,
ioc_columns=ioc_columns,
message=f"Successfully uploaded {len(rows)} rows with {len(ioc_columns)} IOC columns detected",
)
@router.get(
"",
response_model=DatasetListResponse,
summary="List datasets",
)
async def list_datasets(
hunt_id: str | None = Query(None),
limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
):
repo = DatasetRepository(db)
datasets = await repo.list_datasets(hunt_id=hunt_id, limit=limit, offset=offset)
total = await repo.count_datasets(hunt_id=hunt_id)
return DatasetListResponse(
datasets=[
DatasetSummary(
id=ds.id,
name=ds.name,
filename=ds.filename,
source_tool=ds.source_tool,
row_count=ds.row_count,
column_schema=ds.column_schema,
normalized_columns=ds.normalized_columns,
ioc_columns=ds.ioc_columns,
file_size_bytes=ds.file_size_bytes,
encoding=ds.encoding,
delimiter=ds.delimiter,
time_range_start=ds.time_range_start.isoformat() if ds.time_range_start else None,
time_range_end=ds.time_range_end.isoformat() if ds.time_range_end else None,
hunt_id=ds.hunt_id,
created_at=ds.created_at.isoformat(),
)
for ds in datasets
],
total=total,
)
@router.get(
"/{dataset_id}",
response_model=DatasetSummary,
summary="Get dataset details",
)
async def get_dataset(
dataset_id: str,
db: AsyncSession = Depends(get_db),
):
repo = DatasetRepository(db)
ds = await repo.get_dataset(dataset_id)
if not ds:
raise HTTPException(status_code=404, detail="Dataset not found")
return DatasetSummary(
id=ds.id,
name=ds.name,
filename=ds.filename,
source_tool=ds.source_tool,
row_count=ds.row_count,
column_schema=ds.column_schema,
normalized_columns=ds.normalized_columns,
ioc_columns=ds.ioc_columns,
file_size_bytes=ds.file_size_bytes,
encoding=ds.encoding,
delimiter=ds.delimiter,
time_range_start=ds.time_range_start.isoformat() if ds.time_range_start else None,
time_range_end=ds.time_range_end.isoformat() if ds.time_range_end else None,
hunt_id=ds.hunt_id,
created_at=ds.created_at.isoformat(),
)
@router.get(
"/{dataset_id}/rows",
response_model=RowsResponse,
summary="Get dataset rows",
)
async def get_dataset_rows(
dataset_id: str,
limit: int = Query(1000, ge=1, le=10000),
offset: int = Query(0, ge=0),
normalized: bool = Query(False, description="Return normalized column names"),
db: AsyncSession = Depends(get_db),
):
repo = DatasetRepository(db)
ds = await repo.get_dataset(dataset_id)
if not ds:
raise HTTPException(status_code=404, detail="Dataset not found")
rows = await repo.get_rows(dataset_id, limit=limit, offset=offset)
total = await repo.count_rows(dataset_id)
return RowsResponse(
rows=[
(r.normalized_data if normalized and r.normalized_data else r.data)
for r in rows
],
total=total,
offset=offset,
limit=limit,
)
@router.delete(
"/{dataset_id}",
summary="Delete a dataset",
)
async def delete_dataset(
dataset_id: str,
db: AsyncSession = Depends(get_db),
):
repo = DatasetRepository(db)
deleted = await repo.delete_dataset(dataset_id)
if not deleted:
raise HTTPException(status_code=404, detail="Dataset not found")
return {"message": "Dataset deleted", "id": dataset_id}

View File

@@ -0,0 +1,220 @@
"""API routes for IOC enrichment."""
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_db
from app.services.enrichment import (
enrichment_engine,
IOCType,
Verdict,
EnrichmentResultData,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/enrichment", tags=["enrichment"])
# ── Models ────────────────────────────────────────────────────────────
class EnrichIOCRequest(BaseModel):
ioc_value: str = Field(..., max_length=2048, description="IOC value to enrich")
ioc_type: str = Field(..., description="IOC type: ip, domain, hash_md5, hash_sha1, hash_sha256, url")
skip_cache: bool = False
class EnrichBatchRequest(BaseModel):
iocs: list[dict] = Field(
...,
description="List of {value, type} pairs",
max_length=50,
)
class EnrichmentResultResponse(BaseModel):
ioc_value: str
ioc_type: str
source: str
verdict: str
score: float
tags: list[str] = []
country: str = ""
asn: str = ""
org: str = ""
last_seen: str = ""
raw_data: dict = {}
error: str = ""
latency_ms: int = 0
class EnrichIOCResponse(BaseModel):
ioc_value: str
ioc_type: str
results: list[EnrichmentResultResponse]
overall_verdict: str
overall_score: float
class EnrichBatchResponse(BaseModel):
results: dict[str, list[EnrichmentResultResponse]]
total_enriched: int
def _to_response(r: EnrichmentResultData) -> EnrichmentResultResponse:
return EnrichmentResultResponse(
ioc_value=r.ioc_value,
ioc_type=r.ioc_type.value,
source=r.source,
verdict=r.verdict.value,
score=r.score,
tags=r.tags,
country=r.country,
asn=r.asn,
org=r.org,
last_seen=r.last_seen,
raw_data=r.raw_data,
error=r.error,
latency_ms=r.latency_ms,
)
def _compute_overall(results: list[EnrichmentResultData]) -> tuple[str, float]:
"""Compute overall verdict from multiple provider results."""
if not results:
return Verdict.UNKNOWN.value, 0.0
verdicts = [r.verdict for r in results if r.verdict != Verdict.ERROR]
if not verdicts:
return Verdict.ERROR.value, 0.0
if Verdict.MALICIOUS in verdicts:
return Verdict.MALICIOUS.value, max(r.score for r in results)
elif Verdict.SUSPICIOUS in verdicts:
return Verdict.SUSPICIOUS.value, max(r.score for r in results)
elif Verdict.CLEAN in verdicts:
return Verdict.CLEAN.value, 0.0
return Verdict.UNKNOWN.value, 0.0
# ── Routes ────────────────────────────────────────────────────────────
@router.post(
"/ioc",
response_model=EnrichIOCResponse,
summary="Enrich a single IOC",
description="Query all configured providers for an IOC (IP, hash, domain, URL).",
)
async def enrich_ioc(
body: EnrichIOCRequest,
db: AsyncSession = Depends(get_db),
):
try:
ioc_type = IOCType(body.ioc_type)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Invalid IOC type: {body.ioc_type}. Valid: {[t.value for t in IOCType]}",
)
results = await enrichment_engine.enrich_ioc(
body.ioc_value, ioc_type, db=db, skip_cache=body.skip_cache,
)
overall_verdict, overall_score = _compute_overall(results)
return EnrichIOCResponse(
ioc_value=body.ioc_value,
ioc_type=body.ioc_type,
results=[_to_response(r) for r in results],
overall_verdict=overall_verdict,
overall_score=overall_score,
)
@router.post(
"/batch",
response_model=EnrichBatchResponse,
summary="Enrich a batch of IOCs",
description="Enrich up to 50 IOCs at once across all providers.",
)
async def enrich_batch(
body: EnrichBatchRequest,
db: AsyncSession = Depends(get_db),
):
iocs = []
for item in body.iocs:
try:
ioc_type = IOCType(item["type"])
iocs.append((item["value"], ioc_type))
except (KeyError, ValueError):
continue
if not iocs:
raise HTTPException(status_code=400, detail="No valid IOCs provided")
all_results = await enrichment_engine.enrich_batch(iocs, db=db)
return EnrichBatchResponse(
results={
k: [_to_response(r) for r in v]
for k, v in all_results.items()
},
total_enriched=len(all_results),
)
@router.post(
"/dataset/{dataset_id}",
summary="Auto-enrich IOCs in a dataset",
description="Automatically extract and enrich IOCs from a dataset's IOC columns.",
)
async def enrich_dataset(
dataset_id: str,
max_iocs: int = Query(50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
):
from app.db.repositories.datasets import DatasetRepository
repo = DatasetRepository(db)
dataset = await repo.get_dataset(dataset_id)
if not dataset:
raise HTTPException(status_code=404, detail="Dataset not found")
if not dataset.ioc_columns:
return {"message": "No IOC columns detected in this dataset", "results": {}}
rows = await repo.get_rows(dataset_id, limit=1000)
row_data = [r.data for r in rows]
all_results = await enrichment_engine.enrich_dataset_iocs(
rows=row_data,
ioc_columns=dataset.ioc_columns,
db=db,
max_iocs=max_iocs,
)
return {
"dataset_id": dataset_id,
"dataset_name": dataset.name,
"ioc_columns": dataset.ioc_columns,
"results": {
k: [_to_response(r) for r in v]
for k, v in all_results.items()
},
"total_enriched": len(all_results),
}
@router.get(
"/status",
summary="Enrichment engine status",
description="Check which providers are configured and available.",
)
async def enrichment_status():
return enrichment_engine.status()

View File

@@ -1,126 +0,0 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel
from datetime import datetime
from app.core.database import get_db
from app.core.deps import get_current_active_user, get_tenant_id
from app.models.user import User
from app.models.host import Host
router = APIRouter()
class HostCreate(BaseModel):
hostname: str
ip_address: Optional[str] = None
os: Optional[str] = None
host_metadata: Optional[dict] = None
class HostRead(BaseModel):
id: int
hostname: str
ip_address: Optional[str] = None
os: Optional[str] = None
tenant_id: int
host_metadata: Optional[dict] = None
created_at: datetime
last_seen: datetime
class Config:
from_attributes = True
@router.get("/", response_model=List[HostRead])
async def list_hosts(
skip: int = 0,
limit: int = 100,
hostname: Optional[str] = None,
ip_address: Optional[str] = None,
os: Optional[str] = None,
sort_by: Optional[str] = None,
sort_desc: bool = False,
current_user: User = Depends(get_current_active_user),
tenant_id: int = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""
List hosts scoped to user's tenant with advanced filtering
Supports:
- Filtering by hostname, IP address, OS
- Sorting by any field
- Pagination
"""
query = db.query(Host).filter(Host.tenant_id == tenant_id)
# Apply filters
if hostname:
query = query.filter(Host.hostname.ilike(f"%{hostname}%"))
if ip_address:
query = query.filter(Host.ip_address.ilike(f"%{ip_address}%"))
if os:
query = query.filter(Host.os.ilike(f"%{os}%"))
# Apply sorting
if sort_by:
sort_column = getattr(Host, sort_by, None)
if sort_column:
if sort_desc:
query = query.order_by(sort_column.desc())
else:
query = query.order_by(sort_column)
hosts = query.offset(skip).limit(limit).all()
return hosts
@router.post("/", response_model=HostRead, status_code=status.HTTP_201_CREATED)
async def create_host(
host_data: HostCreate,
current_user: User = Depends(get_current_active_user),
tenant_id: int = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""
Create a new host
"""
new_host = Host(
hostname=host_data.hostname,
ip_address=host_data.ip_address,
os=host_data.os,
tenant_id=tenant_id,
host_metadata=host_data.host_metadata
)
db.add(new_host)
db.commit()
db.refresh(new_host)
return new_host
@router.get("/{host_id}", response_model=HostRead)
async def get_host(
host_id: int,
current_user: User = Depends(get_current_active_user),
tenant_id: int = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""
Get host by ID (scoped to tenant)
"""
host = db.query(Host).filter(
Host.id == host_id,
Host.tenant_id == tenant_id
).first()
if not host:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Host not found"
)
return host

View File

@@ -0,0 +1,158 @@
"""API routes for hunt management."""
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_db
from app.db.models import Hunt, Conversation, Message
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/hunts", tags=["hunts"])
# ── Models ────────────────────────────────────────────────────────────
class HuntCreate(BaseModel):
name: str = Field(..., max_length=256)
description: str | None = None
class HuntUpdate(BaseModel):
name: str | None = None
description: str | None = None
status: str | None = None # active | closed | archived
class HuntResponse(BaseModel):
id: str
name: str
description: str | None
status: str
owner_id: str | None
created_at: str
updated_at: str
dataset_count: int = 0
hypothesis_count: int = 0
class HuntListResponse(BaseModel):
hunts: list[HuntResponse]
total: int
# ── Routes ────────────────────────────────────────────────────────────
@router.post("", response_model=HuntResponse, summary="Create a new hunt")
async def create_hunt(body: HuntCreate, db: AsyncSession = Depends(get_db)):
hunt = Hunt(name=body.name, description=body.description)
db.add(hunt)
await db.flush()
return HuntResponse(
id=hunt.id,
name=hunt.name,
description=hunt.description,
status=hunt.status,
owner_id=hunt.owner_id,
created_at=hunt.created_at.isoformat(),
updated_at=hunt.updated_at.isoformat(),
)
@router.get("", response_model=HuntListResponse, summary="List hunts")
async def list_hunts(
status: str | None = Query(None),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
db: AsyncSession = Depends(get_db),
):
stmt = select(Hunt).order_by(Hunt.updated_at.desc())
if status:
stmt = stmt.where(Hunt.status == status)
stmt = stmt.limit(limit).offset(offset)
result = await db.execute(stmt)
hunts = result.scalars().all()
count_stmt = select(func.count(Hunt.id))
if status:
count_stmt = count_stmt.where(Hunt.status == status)
total = (await db.execute(count_stmt)).scalar_one()
return HuntListResponse(
hunts=[
HuntResponse(
id=h.id,
name=h.name,
description=h.description,
status=h.status,
owner_id=h.owner_id,
created_at=h.created_at.isoformat(),
updated_at=h.updated_at.isoformat(),
dataset_count=len(h.datasets) if h.datasets else 0,
hypothesis_count=len(h.hypotheses) if h.hypotheses else 0,
)
for h in hunts
],
total=total,
)
@router.get("/{hunt_id}", response_model=HuntResponse, summary="Get hunt details")
async def get_hunt(hunt_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Hunt).where(Hunt.id == hunt_id))
hunt = result.scalar_one_or_none()
if not hunt:
raise HTTPException(status_code=404, detail="Hunt not found")
return HuntResponse(
id=hunt.id,
name=hunt.name,
description=hunt.description,
status=hunt.status,
owner_id=hunt.owner_id,
created_at=hunt.created_at.isoformat(),
updated_at=hunt.updated_at.isoformat(),
dataset_count=len(hunt.datasets) if hunt.datasets else 0,
hypothesis_count=len(hunt.hypotheses) if hunt.hypotheses else 0,
)
@router.put("/{hunt_id}", response_model=HuntResponse, summary="Update a hunt")
async def update_hunt(
hunt_id: str, body: HuntUpdate, db: AsyncSession = Depends(get_db)
):
result = await db.execute(select(Hunt).where(Hunt.id == hunt_id))
hunt = result.scalar_one_or_none()
if not hunt:
raise HTTPException(status_code=404, detail="Hunt not found")
if body.name is not None:
hunt.name = body.name
if body.description is not None:
hunt.description = body.description
if body.status is not None:
hunt.status = body.status
await db.flush()
return HuntResponse(
id=hunt.id,
name=hunt.name,
description=hunt.description,
status=hunt.status,
owner_id=hunt.owner_id,
created_at=hunt.created_at.isoformat(),
updated_at=hunt.updated_at.isoformat(),
)
@router.delete("/{hunt_id}", summary="Delete a hunt")
async def delete_hunt(hunt_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Hunt).where(Hunt.id == hunt_id))
hunt = result.scalar_one_or_none()
if not hunt:
raise HTTPException(status_code=404, detail="Hunt not found")
await db.delete(hunt)
return {"message": "Hunt deleted", "id": hunt_id}

View File

@@ -1,60 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import Optional, Dict, Any
from app.core.database import get_db
from app.core.deps import get_current_active_user, get_tenant_id
from app.models.user import User
from app.models.host import Host
router = APIRouter()
class IngestionData(BaseModel):
hostname: str
data: Dict[str, Any]
class IngestionResponse(BaseModel):
message: str
host_id: int
@router.post("/ingest", response_model=IngestionResponse)
async def ingest_data(
ingestion: IngestionData,
current_user: User = Depends(get_current_active_user),
tenant_id: int = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""
Ingest data from Velociraptor
Creates or updates host information scoped to the user's tenant.
"""
# Find or create host
host = db.query(Host).filter(
Host.hostname == ingestion.hostname,
Host.tenant_id == tenant_id
).first()
if host:
# Update existing host
host.host_metadata = ingestion.data
else:
# Create new host
host = Host(
hostname=ingestion.hostname,
tenant_id=tenant_id,
host_metadata=ingestion.data
)
db.add(host)
db.commit()
db.refresh(host)
return {
"message": "Data ingested successfully",
"host_id": host.id
}

View File

@@ -0,0 +1,257 @@
"""API routes for AUP keyword themes, keyword CRUD, and scanning."""
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import select, func, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_db
from app.db.models import KeywordTheme, Keyword
from app.services.scanner import KeywordScanner
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/keywords", tags=["keywords"])
# ── Pydantic schemas ──────────────────────────────────────────────────
class ThemeCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=128)
color: str = Field(default="#9e9e9e", max_length=16)
enabled: bool = True
class ThemeUpdate(BaseModel):
name: str | None = None
color: str | None = None
enabled: bool | None = None
class KeywordOut(BaseModel):
id: int
theme_id: str
value: str
is_regex: bool
created_at: str
class ThemeOut(BaseModel):
id: str
name: str
color: str
enabled: bool
is_builtin: bool
created_at: str
keyword_count: int
keywords: list[KeywordOut]
class ThemeListResponse(BaseModel):
themes: list[ThemeOut]
total: int
class KeywordCreate(BaseModel):
value: str = Field(..., min_length=1, max_length=256)
is_regex: bool = False
class KeywordBulkCreate(BaseModel):
values: list[str] = Field(..., min_items=1)
is_regex: bool = False
class ScanRequest(BaseModel):
dataset_ids: list[str] | None = None # None → all datasets
theme_ids: list[str] | None = None # None → all enabled themes
scan_hunts: bool = True
scan_annotations: bool = True
scan_messages: bool = True
class ScanHit(BaseModel):
theme_name: str
theme_color: str
keyword: str
source_type: str # dataset_row | hunt | annotation | message
source_id: str | int
field: str
matched_value: str
row_index: int | None = None
dataset_name: str | None = None
class ScanResponse(BaseModel):
total_hits: int
hits: list[ScanHit]
themes_scanned: int
keywords_scanned: int
rows_scanned: int
# ── Helpers ───────────────────────────────────────────────────────────
def _theme_to_out(t: KeywordTheme) -> ThemeOut:
return ThemeOut(
id=t.id,
name=t.name,
color=t.color,
enabled=t.enabled,
is_builtin=t.is_builtin,
created_at=t.created_at.isoformat(),
keyword_count=len(t.keywords),
keywords=[
KeywordOut(
id=k.id,
theme_id=k.theme_id,
value=k.value,
is_regex=k.is_regex,
created_at=k.created_at.isoformat(),
)
for k in t.keywords
],
)
# ── Theme CRUD ────────────────────────────────────────────────────────
@router.get("/themes", response_model=ThemeListResponse)
async def list_themes(db: AsyncSession = Depends(get_db)):
"""List all keyword themes with their keywords."""
result = await db.execute(
select(KeywordTheme).order_by(KeywordTheme.name)
)
themes = result.scalars().all()
return ThemeListResponse(
themes=[_theme_to_out(t) for t in themes],
total=len(themes),
)
@router.post("/themes", response_model=ThemeOut, status_code=201)
async def create_theme(body: ThemeCreate, db: AsyncSession = Depends(get_db)):
"""Create a new keyword theme."""
exists = await db.scalar(
select(KeywordTheme.id).where(KeywordTheme.name == body.name)
)
if exists:
raise HTTPException(409, f"Theme '{body.name}' already exists")
theme = KeywordTheme(name=body.name, color=body.color, enabled=body.enabled)
db.add(theme)
await db.flush()
await db.refresh(theme)
return _theme_to_out(theme)
@router.put("/themes/{theme_id}", response_model=ThemeOut)
async def update_theme(theme_id: str, body: ThemeUpdate, db: AsyncSession = Depends(get_db)):
"""Update theme name, color, or enabled status."""
theme = await db.get(KeywordTheme, theme_id)
if not theme:
raise HTTPException(404, "Theme not found")
if body.name is not None:
# check uniqueness
dup = await db.scalar(
select(KeywordTheme.id).where(
KeywordTheme.name == body.name, KeywordTheme.id != theme_id
)
)
if dup:
raise HTTPException(409, f"Theme '{body.name}' already exists")
theme.name = body.name
if body.color is not None:
theme.color = body.color
if body.enabled is not None:
theme.enabled = body.enabled
await db.flush()
await db.refresh(theme)
return _theme_to_out(theme)
@router.delete("/themes/{theme_id}", status_code=204)
async def delete_theme(theme_id: str, db: AsyncSession = Depends(get_db)):
"""Delete a theme and all its keywords."""
theme = await db.get(KeywordTheme, theme_id)
if not theme:
raise HTTPException(404, "Theme not found")
await db.delete(theme)
# ── Keyword CRUD ──────────────────────────────────────────────────────
@router.post("/themes/{theme_id}/keywords", response_model=KeywordOut, status_code=201)
async def add_keyword(theme_id: str, body: KeywordCreate, db: AsyncSession = Depends(get_db)):
"""Add a single keyword to a theme."""
theme = await db.get(KeywordTheme, theme_id)
if not theme:
raise HTTPException(404, "Theme not found")
kw = Keyword(theme_id=theme_id, value=body.value, is_regex=body.is_regex)
db.add(kw)
await db.flush()
await db.refresh(kw)
return KeywordOut(
id=kw.id, theme_id=kw.theme_id, value=kw.value,
is_regex=kw.is_regex, created_at=kw.created_at.isoformat(),
)
@router.post("/themes/{theme_id}/keywords/bulk", response_model=dict, status_code=201)
async def add_keywords_bulk(theme_id: str, body: KeywordBulkCreate, db: AsyncSession = Depends(get_db)):
"""Add multiple keywords to a theme at once."""
theme = await db.get(KeywordTheme, theme_id)
if not theme:
raise HTTPException(404, "Theme not found")
added = 0
for val in body.values:
val = val.strip()
if not val:
continue
db.add(Keyword(theme_id=theme_id, value=val, is_regex=body.is_regex))
added += 1
await db.flush()
return {"added": added, "theme_id": theme_id}
@router.delete("/keywords/{keyword_id}", status_code=204)
async def delete_keyword(keyword_id: int, db: AsyncSession = Depends(get_db)):
"""Delete a single keyword."""
kw = await db.get(Keyword, keyword_id)
if not kw:
raise HTTPException(404, "Keyword not found")
await db.delete(kw)
# ── Scan endpoints ────────────────────────────────────────────────────
@router.post("/scan", response_model=ScanResponse)
async def run_scan(body: ScanRequest, db: AsyncSession = Depends(get_db)):
"""Run AUP keyword scan across selected data sources."""
scanner = KeywordScanner(db)
result = await scanner.scan(
dataset_ids=body.dataset_ids,
theme_ids=body.theme_ids,
scan_hunts=body.scan_hunts,
scan_annotations=body.scan_annotations,
scan_messages=body.scan_messages,
)
return result
@router.get("/scan/quick", response_model=ScanResponse)
async def quick_scan(
dataset_id: str = Query(..., description="Dataset to scan"),
db: AsyncSession = Depends(get_db),
):
"""Quick scan a single dataset with all enabled themes."""
scanner = KeywordScanner(db)
result = await scanner.scan(dataset_ids=[dataset_id])
return result

View File

@@ -1,250 +0,0 @@
from typing import Dict, Any, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel
from app.core.database import get_db
from app.core.deps import get_current_active_user, require_role
from app.core.llm_router import get_llm_router, TaskType
from app.core.job_scheduler import get_job_scheduler, Job, NodeStatus
from app.core.llm_pool import get_llm_pool
from app.core.merger_agent import get_merger_agent, MergeStrategy
from app.models.user import User
router = APIRouter()
class LLMRequest(BaseModel):
"""Request for LLM processing"""
prompt: str
task_hints: Optional[List[str]] = []
requires_parallel: bool = False
requires_chaining: bool = False
batch_size: int = 1
operations: Optional[List[str]] = []
parameters: Optional[Dict[str, Any]] = None
class LLMResponse(BaseModel):
"""Response from LLM processing"""
job_id: str
result: Any
execution_mode: str
models_used: List[str]
strategy: Optional[str] = None
class NodeStatusUpdate(BaseModel):
"""Update node status"""
node_id: str
vram_used_gb: Optional[int] = None
compute_utilization: Optional[float] = None
status: Optional[str] = None
@router.post("/process", response_model=Dict[str, Any])
async def process_llm_request(
request: LLMRequest,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Process an LLM request through the distributed routing system
The request flows through:
1. Router Agent - classifies and routes to appropriate model
2. Job Scheduler - determines execution strategy
3. LLM Pool - executes on appropriate endpoints
4. Merger Agent - combines results if multiple models used
"""
# Step 1: Route the request
router_agent = get_llm_router()
routing_decision = router_agent.route_request(request.dict())
# Step 2: Schedule the job
scheduler = get_job_scheduler()
job = Job(
job_id=f"job_{current_user.id}_{hash(request.prompt) % 10000}",
model=routing_decision["model"],
priority=routing_decision["priority"],
estimated_vram_gb=10, # Estimate based on model
requires_parallel=request.requires_parallel,
requires_chaining=request.requires_chaining,
payload=request.dict()
)
scheduling_decision = await scheduler.schedule_job(job)
# Step 3: Execute on LLM pool
pool = get_llm_pool()
if scheduling_decision["execution_mode"] == "parallel":
# Execute on multiple nodes
model_names = [routing_decision["model"]] * len(scheduling_decision["nodes"])
results = await pool.call_multiple_models(
model_names,
request.prompt,
request.parameters
)
# Step 4: Merge results
merger = get_merger_agent()
final_result = merger.merge_results(
results["results"],
strategy=MergeStrategy.CONSENSUS
)
return {
"job_id": job.job_id,
"status": "completed",
"routing": routing_decision,
"scheduling": scheduling_decision,
"result": final_result,
"execution_mode": "parallel"
}
elif scheduling_decision["execution_mode"] == "queued":
return {
"job_id": job.job_id,
"status": "queued",
"queue_position": scheduling_decision["queue_position"],
"message": "Job queued - no nodes available"
}
else:
# Single node execution
result = await pool.call_model(
routing_decision["model"],
request.prompt,
request.parameters
)
return {
"job_id": job.job_id,
"status": "completed",
"routing": routing_decision,
"scheduling": scheduling_decision,
"result": result,
"execution_mode": scheduling_decision["execution_mode"]
}
@router.get("/models")
async def list_available_models(
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
List all available LLM models in the pool
"""
pool = get_llm_pool()
models = pool.list_available_models()
return {
"models": models,
"total": len(models)
}
@router.get("/nodes")
async def list_gpu_nodes(
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
List all GPU nodes and their status
"""
scheduler = get_job_scheduler()
nodes = scheduler.get_available_nodes()
return {
"nodes": [
{
"node_id": node.node_id,
"hostname": node.hostname,
"vram_total_gb": node.vram_total_gb,
"vram_used_gb": node.vram_used_gb,
"vram_available_gb": node.vram_available_gb,
"compute_utilization": node.compute_utilization,
"status": node.status.value,
"models_loaded": node.models_loaded
}
for node in scheduler.nodes.values()
],
"available_count": len(nodes)
}
@router.post("/nodes/status")
async def update_node_status(
update: NodeStatusUpdate,
current_user: User = Depends(require_role(["admin"])),
db: Session = Depends(get_db)
):
"""
Update GPU node status (admin only)
"""
scheduler = get_job_scheduler()
status_enum = None
if update.status:
try:
status_enum = NodeStatus[update.status.upper()]
except KeyError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid status: {update.status}"
)
scheduler.update_node_status(
update.node_id,
vram_used_gb=update.vram_used_gb,
compute_utilization=update.compute_utilization,
status=status_enum
)
return {"message": "Node status updated"}
@router.get("/routing/rules")
async def get_routing_rules(
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Get current routing rules for task classification
"""
router_agent = get_llm_router()
return {
"routing_rules": {
task_type.value: {
"model": rule["model"],
"endpoint": rule["endpoint"],
"priority": rule["priority"],
"description": rule["description"]
}
for task_type, rule in router_agent.routing_rules.items()
}
}
@router.post("/test-classification")
async def test_classification(
request: LLMRequest,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Test task classification without executing
"""
router_agent = get_llm_router()
task_type = router_agent.classify_request(request.dict())
routing_decision = router_agent.route_request(request.dict())
return {
"task_type": task_type.value,
"routing_decision": routing_decision,
"should_parallelize": router_agent.should_parallelize(request.dict()),
"requires_chaining": router_agent.requires_serial_chaining(request.dict())
}

View File

@@ -0,0 +1,28 @@
"""Network topology API - host inventory endpoint."""
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_db
from app.services.host_inventory import build_host_inventory
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/network", tags=["network"])
@router.get("/host-inventory")
async def get_host_inventory(
hunt_id: str = Query(..., description="Hunt ID to build inventory for"),
db: AsyncSession = Depends(get_db),
):
"""Build a deduplicated host inventory from all datasets in a hunt.
Returns unique hosts with hostname, IPs, OS, logged-in users, and
network connections derived from netstat/connection data.
"""
result = await build_host_inventory(hunt_id, db)
if result["stats"]["total_hosts"] == 0:
return result
return result

View File

@@ -1,164 +0,0 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status, WebSocket, WebSocketDisconnect
from sqlalchemy.orm import Session
import json
from app.core.database import get_db
from app.core.deps import get_current_active_user
from app.models.user import User
from app.models.notification import Notification
from app.schemas.notification import NotificationRead, NotificationUpdate
router = APIRouter()
# Store active WebSocket connections
active_connections: dict[int, List[WebSocket]] = {}
@router.get("/", response_model=List[NotificationRead])
async def list_notifications(
skip: int = 0,
limit: int = 50,
unread_only: bool = False,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
List notifications for current user
Supports filtering by read/unread status.
"""
query = db.query(Notification).filter(
Notification.user_id == current_user.id,
Notification.tenant_id == current_user.tenant_id
)
if unread_only:
query = query.filter(Notification.is_read == False)
notifications = query.order_by(Notification.created_at.desc()).offset(skip).limit(limit).all()
return notifications
@router.put("/{notification_id}", response_model=NotificationRead)
async def update_notification(
notification_id: int,
notification_update: NotificationUpdate,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Update notification (mark as read/unread)
"""
notification = db.query(Notification).filter(
Notification.id == notification_id,
Notification.user_id == current_user.id
).first()
if not notification:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Notification not found"
)
notification.is_read = notification_update.is_read
db.commit()
db.refresh(notification)
return notification
@router.post("/mark-all-read")
async def mark_all_read(
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Mark all notifications as read for current user
"""
db.query(Notification).filter(
Notification.user_id == current_user.id,
Notification.is_read == False
).update({"is_read": True})
db.commit()
return {"message": "All notifications marked as read"}
@router.websocket("/ws")
async def websocket_endpoint(
websocket: WebSocket,
db: Session = Depends(get_db)
):
"""
WebSocket endpoint for real-time notifications
Clients should send their token on connect, then receive notifications in real-time.
"""
await websocket.accept()
user_id = None
try:
# Wait for authentication message
auth_data = await websocket.receive_text()
auth_json = json.loads(auth_data)
token = auth_json.get("token")
# Validate token and get user (simplified - in production use proper auth)
from app.core.security import verify_token
payload = verify_token(token)
if payload:
user_id = payload.get("sub")
# Register connection
if user_id not in active_connections:
active_connections[user_id] = []
active_connections[user_id].append(websocket)
# Send confirmation
await websocket.send_json({"type": "connected", "user_id": user_id})
# Keep connection alive
while True:
data = await websocket.receive_text()
# Echo back for keepalive
await websocket.send_json({"type": "pong"})
else:
await websocket.close(code=1008) # Policy violation
except WebSocketDisconnect:
# Remove connection
if user_id and user_id in active_connections:
active_connections[user_id].remove(websocket)
if not active_connections[user_id]:
del active_connections[user_id]
except Exception as e:
print(f"WebSocket error: {e}")
await websocket.close(code=1011) # Internal error
async def send_notification_to_user(user_id: int, notification: dict):
"""
Send notification to all connected clients for a user
Args:
user_id: User ID to send notification to
notification: Notification data to send
"""
if user_id in active_connections:
disconnected = []
for websocket in active_connections[user_id]:
try:
await websocket.send_json({
"type": "notification",
"data": notification
})
except:
disconnected.append(websocket)
# Clean up disconnected websockets
for ws in disconnected:
active_connections[user_id].remove(ws)
if not active_connections[user_id]:
del active_connections[user_id]

View File

@@ -1,144 +0,0 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.deps import get_current_active_user, require_role, get_tenant_id
from app.core.playbook_engine import get_playbook_engine
from app.models.user import User
from app.models.playbook import Playbook, PlaybookExecution
from app.schemas.playbook import PlaybookCreate, PlaybookRead, PlaybookUpdate, PlaybookExecutionRead
router = APIRouter()
@router.get("/", response_model=List[PlaybookRead])
async def list_playbooks(
skip: int = 0,
limit: int = 100,
current_user: User = Depends(get_current_active_user),
tenant_id: int = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""List playbooks scoped to user's tenant"""
playbooks = db.query(Playbook).filter(
Playbook.tenant_id == tenant_id
).offset(skip).limit(limit).all()
return playbooks
@router.post("/", response_model=PlaybookRead, status_code=status.HTTP_201_CREATED)
async def create_playbook(
playbook_data: PlaybookCreate,
current_user: User = Depends(require_role(["admin"])),
tenant_id: int = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""Create a new playbook (admin only)"""
playbook = Playbook(
tenant_id=tenant_id,
created_by=current_user.id,
**playbook_data.dict()
)
db.add(playbook)
db.commit()
db.refresh(playbook)
return playbook
@router.get("/{playbook_id}", response_model=PlaybookRead)
async def get_playbook(
playbook_id: int,
current_user: User = Depends(get_current_active_user),
tenant_id: int = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""Get playbook by ID"""
playbook = db.query(Playbook).filter(
Playbook.id == playbook_id,
Playbook.tenant_id == tenant_id
).first()
if not playbook:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Playbook not found"
)
return playbook
@router.post("/{playbook_id}/execute", response_model=PlaybookExecutionRead)
async def execute_playbook(
playbook_id: int,
current_user: User = Depends(get_current_active_user),
tenant_id: int = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""Execute a playbook"""
playbook = db.query(Playbook).filter(
Playbook.id == playbook_id,
Playbook.tenant_id == tenant_id
).first()
if not playbook:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Playbook not found"
)
if not playbook.is_enabled:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Playbook is disabled"
)
# Create execution record
execution = PlaybookExecution(
playbook_id=playbook_id,
tenant_id=tenant_id,
status="running",
triggered_by=current_user.id
)
db.add(execution)
db.commit()
db.refresh(execution)
# Execute playbook asynchronously
try:
engine = get_playbook_engine()
result = await engine.execute_playbook(
{"actions": playbook.actions},
{"tenant_id": tenant_id, "user_id": current_user.id}
)
execution.status = result["status"]
execution.result = result
from datetime import datetime, timezone
execution.completed_at = datetime.now(timezone.utc)
except Exception as e:
execution.status = "failed"
execution.error_message = str(e)
db.commit()
db.refresh(execution)
return execution
@router.get("/{playbook_id}/executions", response_model=List[PlaybookExecutionRead])
async def list_playbook_executions(
playbook_id: int,
skip: int = 0,
limit: int = 100,
current_user: User = Depends(get_current_active_user),
tenant_id: int = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""List executions for a playbook"""
executions = db.query(PlaybookExecution).filter(
PlaybookExecution.playbook_id == playbook_id,
PlaybookExecution.tenant_id == tenant_id
).order_by(PlaybookExecution.started_at.desc()).offset(skip).limit(limit).all()
return executions

View File

@@ -1,120 +1,67 @@
from typing import List """API routes for report generation and export."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_db import logging
from app.core.deps import get_current_active_user, get_tenant_id
from app.models.user import User
from app.models.report_template import ReportTemplate, Report
from app.schemas.report import ReportTemplateCreate, ReportTemplateRead, ReportCreate, ReportRead
router = APIRouter() from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import HTMLResponse, PlainTextResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.db import get_db
from app.services.reports import report_generator
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/reports", tags=["reports"])
@router.get("/templates", response_model=List[ReportTemplateRead]) @router.get(
async def list_report_templates( "/hunt/{hunt_id}",
skip: int = 0, summary="Generate hunt investigation report",
limit: int = 100, description="Generate a comprehensive report for a hunt. Supports JSON, HTML, and CSV formats.",
current_user: User = Depends(get_current_active_user), )
tenant_id: int = Depends(get_tenant_id), async def generate_hunt_report(
db: Session = Depends(get_db) hunt_id: str,
format: str = Query("json", description="Report format: json, html, csv"),
include_rows: bool = Query(False, description="Include raw data rows"),
max_rows: int = Query(500, ge=0, le=5000, description="Max rows to include"),
db: AsyncSession = Depends(get_db),
): ):
"""List report templates scoped to tenant""" result = await report_generator.generate_hunt_report(
templates = db.query(ReportTemplate).filter( hunt_id, db, format=format,
ReportTemplate.tenant_id == tenant_id include_rows=include_rows, max_rows=max_rows,
).offset(skip).limit(limit).all()
return templates
@router.post("/templates", response_model=ReportTemplateRead, status_code=status.HTTP_201_CREATED)
async def create_report_template(
template_data: ReportTemplateCreate,
current_user: User = Depends(get_current_active_user),
tenant_id: int = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""Create a new report template"""
template = ReportTemplate(
tenant_id=tenant_id,
created_by=current_user.id,
**template_data.dict()
) )
db.add(template)
db.commit() if isinstance(result, dict) and result.get("error"):
db.refresh(template) raise HTTPException(status_code=404, detail=result["error"])
return template
if format == "html":
return HTMLResponse(content=result, headers={
"Content-Disposition": f"inline; filename=threathunt_report_{hunt_id}.html",
})
elif format == "csv":
return PlainTextResponse(content=result, media_type="text/csv", headers={
"Content-Disposition": f"attachment; filename=threathunt_report_{hunt_id}.csv",
})
else:
return result
@router.post("/generate", response_model=ReportRead, status_code=status.HTTP_201_CREATED) @router.get(
async def generate_report( "/hunt/{hunt_id}/summary",
report_data: ReportCreate, summary="Quick hunt summary",
current_user: User = Depends(get_current_active_user), description="Get a lightweight summary of the hunt for dashboard display.",
tenant_id: int = Depends(get_tenant_id), )
db: Session = Depends(get_db) async def hunt_summary(
hunt_id: str,
db: AsyncSession = Depends(get_db),
): ):
""" result = await report_generator.generate_hunt_report(
Generate a new report hunt_id, db, format="json", include_rows=False,
This is a simplified implementation. In production, this would:
1. Fetch relevant data based on report type
2. Apply template formatting
3. Generate PDF/HTML output
4. Store file and return path
"""
report = Report(
tenant_id=tenant_id,
template_id=report_data.template_id,
title=report_data.title,
report_type=report_data.report_type,
format=report_data.format,
status="generating",
generated_by=current_user.id
) )
db.add(report) if isinstance(result, dict) and result.get("error"):
db.commit() raise HTTPException(status_code=404, detail=result["error"])
# Simulate report generation
# In production, this would be an async task
report.status = "completed"
report.file_path = f"/reports/{report.id}.{report_data.format}"
db.commit()
db.refresh(report)
return report
return {
@router.get("/", response_model=List[ReportRead]) "hunt": result.get("hunt"),
async def list_reports( "summary": result.get("summary"),
skip: int = 0, }
limit: int = 100,
current_user: User = Depends(get_current_active_user),
tenant_id: int = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""List generated reports"""
reports = db.query(Report).filter(
Report.tenant_id == tenant_id
).order_by(Report.generated_at.desc()).offset(skip).limit(limit).all()
return reports
@router.get("/{report_id}", response_model=ReportRead)
async def get_report(
report_id: int,
current_user: User = Depends(get_current_active_user),
tenant_id: int = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""Get a specific report"""
report = db.query(Report).filter(
Report.id == report_id,
Report.tenant_id == tenant_id
).first()
if not report:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Report not found"
)
return report

View File

@@ -1,103 +0,0 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.deps import get_current_active_user, require_role
from app.models.user import User
from app.models.tenant import Tenant
from pydantic import BaseModel
router = APIRouter()
class TenantCreate(BaseModel):
name: str
description: str = None
class TenantRead(BaseModel):
id: int
name: str
description: str = None
class Config:
from_attributes = True
@router.get("/", response_model=List[TenantRead])
async def list_tenants(
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
List tenants
Regular users can only see their own tenant.
Admins can see all tenants (cross-tenant access).
"""
if current_user.role == "admin":
# Admins can see all tenants
tenants = db.query(Tenant).all()
else:
# Regular users only see their tenant
tenants = db.query(Tenant).filter(Tenant.id == current_user.tenant_id).all()
return tenants
@router.post("/", response_model=TenantRead, status_code=status.HTTP_201_CREATED)
async def create_tenant(
tenant_data: TenantCreate,
current_user: User = Depends(require_role(["admin"])),
db: Session = Depends(get_db)
):
"""
Create a new tenant (admin only)
"""
# Check if tenant name already exists
existing_tenant = db.query(Tenant).filter(Tenant.name == tenant_data.name).first()
if existing_tenant:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Tenant name already exists"
)
new_tenant = Tenant(
name=tenant_data.name,
description=tenant_data.description
)
db.add(new_tenant)
db.commit()
db.refresh(new_tenant)
return new_tenant
@router.get("/{tenant_id}", response_model=TenantRead)
async def get_tenant(
tenant_id: int,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Get tenant by ID
Users can only view their own tenant unless they are admin.
"""
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
if not tenant:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Tenant not found"
)
# Check permissions
if current_user.role != "admin" and tenant.id != current_user.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to view this tenant"
)
return tenant

View File

@@ -1,127 +0,0 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.deps import get_current_active_user, get_tenant_id
from app.core.threat_intel import get_threat_analyzer
from app.models.user import User
from app.models.threat_score import ThreatScore
from app.models.host import Host
from app.models.artifact import Artifact
from app.schemas.threat_score import ThreatScoreRead, ThreatScoreCreate
router = APIRouter()
@router.post("/analyze/host/{host_id}", response_model=ThreatScoreRead)
async def analyze_host(
host_id: int,
current_user: User = Depends(get_current_active_user),
tenant_id: int = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""
Analyze a host for threats using ML
"""
host = db.query(Host).filter(
Host.id == host_id,
Host.tenant_id == tenant_id
).first()
if not host:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Host not found"
)
# Analyze host
analyzer = get_threat_analyzer()
analysis = analyzer.analyze_host({
"hostname": host.hostname,
"ip_address": host.ip_address,
"os": host.os,
"host_metadata": host.host_metadata
})
# Store threat score
threat_score = ThreatScore(
tenant_id=tenant_id,
host_id=host_id,
score=analysis["score"],
confidence=analysis["confidence"],
threat_type=analysis["threat_type"],
indicators=analysis["indicators"],
ml_model_version=analysis["ml_model_version"]
)
db.add(threat_score)
db.commit()
db.refresh(threat_score)
return threat_score
@router.post("/analyze/artifact/{artifact_id}", response_model=ThreatScoreRead)
async def analyze_artifact(
artifact_id: int,
current_user: User = Depends(get_current_active_user),
tenant_id: int = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""
Analyze an artifact for threats
"""
artifact = db.query(Artifact).filter(Artifact.id == artifact_id).first()
if not artifact:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Artifact not found"
)
# Analyze artifact
analyzer = get_threat_analyzer()
analysis = analyzer.analyze_artifact({
"artifact_type": artifact.artifact_type,
"value": artifact.value
})
# Store threat score
threat_score = ThreatScore(
tenant_id=tenant_id,
artifact_id=artifact_id,
score=analysis["score"],
confidence=analysis["confidence"],
threat_type=analysis["threat_type"],
indicators=analysis["indicators"],
ml_model_version=analysis["ml_model_version"]
)
db.add(threat_score)
db.commit()
db.refresh(threat_score)
return threat_score
@router.get("/scores", response_model=List[ThreatScoreRead])
async def list_threat_scores(
skip: int = 0,
limit: int = 100,
min_score: float = 0.0,
threat_type: str = None,
current_user: User = Depends(get_current_active_user),
tenant_id: int = Depends(get_tenant_id),
db: Session = Depends(get_db)
):
"""
List threat scores with filtering
"""
query = db.query(ThreatScore).filter(ThreatScore.tenant_id == tenant_id)
if min_score:
query = query.filter(ThreatScore.score >= min_score)
if threat_type:
query = query.filter(ThreatScore.threat_type == threat_type)
scores = query.order_by(ThreatScore.score.desc()).offset(skip).limit(limit).all()
return scores

View File

@@ -1,154 +0,0 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.deps import get_current_active_user, require_role
from app.core.security import get_password_hash
from app.models.user import User
from app.schemas.user import UserRead, UserUpdate, UserCreate
router = APIRouter()
@router.get("/", response_model=List[UserRead])
async def list_users(
skip: int = 0,
limit: int = 100,
current_user: User = Depends(require_role(["admin"])),
db: Session = Depends(get_db)
):
"""
List all users (admin only, scoped to tenant)
Admins can only see users within their own tenant unless they have
cross-tenant access.
"""
# Scope to tenant
query = db.query(User).filter(User.tenant_id == current_user.tenant_id)
users = query.offset(skip).limit(limit).all()
return users
@router.get("/{user_id}", response_model=UserRead)
async def get_user(
user_id: int,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Get user by ID
Users can view their own profile or admins can view users in their tenant.
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Check permissions: user can view themselves or admin can view users in their tenant
if user.id != current_user.id and (
current_user.role != "admin" or user.tenant_id != current_user.tenant_id
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to view this user"
)
return user
@router.put("/{user_id}", response_model=UserRead)
async def update_user(
user_id: int,
user_update: UserUpdate,
current_user: User = Depends(require_role(["admin"])),
db: Session = Depends(get_db)
):
"""
Update user (admin only)
Admins can update users within their tenant.
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Check tenant access
if user.tenant_id != current_user.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to update this user"
)
# Update fields
if user_update.username is not None:
# Check if new username is already taken
existing_user = db.query(User).filter(
User.username == user_update.username,
User.id != user_id
).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username already taken"
)
user.username = user_update.username
if user_update.password is not None:
user.password_hash = get_password_hash(user_update.password)
if user_update.role is not None:
user.role = user_update.role
if user_update.is_active is not None:
user.is_active = user_update.is_active
db.commit()
db.refresh(user)
return user
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
current_user: User = Depends(require_role(["admin"])),
db: Session = Depends(get_db)
):
"""
Deactivate user (admin only)
Soft delete by setting is_active to False.
"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Check tenant access
if user.tenant_id != current_user.tenant_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to delete this user"
)
# Prevent self-deletion
if user.id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot delete your own account"
)
# Soft delete
user.is_active = False
db.commit()
return None

View File

@@ -1,197 +0,0 @@
from typing import List, Dict, Any, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel
from app.core.database import get_db
from app.core.deps import get_current_active_user, require_role
from app.core.velociraptor import get_velociraptor_client
from app.models.user import User
router = APIRouter()
class VelociraptorConfig(BaseModel):
"""Velociraptor server configuration"""
base_url: str
api_key: str
class ArtifactCollectionRequest(BaseModel):
"""Request to collect an artifact"""
client_id: str
artifact_name: str
parameters: Optional[Dict[str, Any]] = None
class HuntCreateRequest(BaseModel):
"""Request to create a hunt"""
hunt_name: str
artifact_name: str
description: str
parameters: Optional[Dict[str, Any]] = None
# In a real implementation, this would be stored per-tenant in database
# For now, using a simple in-memory store
velociraptor_configs: Dict[int, VelociraptorConfig] = {}
@router.post("/config")
async def set_velociraptor_config(
config: VelociraptorConfig,
current_user: User = Depends(require_role(["admin"])),
db: Session = Depends(get_db)
):
"""
Configure Velociraptor integration (admin only)
Stores Velociraptor server URL and API key for the tenant.
"""
velociraptor_configs[current_user.tenant_id] = config
return {"message": "Velociraptor configuration saved"}
@router.get("/clients")
async def list_velociraptor_clients(
limit: int = 100,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
List clients from Velociraptor server
"""
config = velociraptor_configs.get(current_user.tenant_id)
if not config:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Velociraptor not configured for this tenant"
)
client = get_velociraptor_client(config.base_url, config.api_key)
try:
clients = await client.list_clients(limit=limit)
return {"clients": clients}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch clients: {str(e)}"
)
@router.get("/clients/{client_id}")
async def get_velociraptor_client_info(
client_id: str,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Get information about a specific Velociraptor client
"""
config = velociraptor_configs.get(current_user.tenant_id)
if not config:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Velociraptor not configured for this tenant"
)
client = get_velociraptor_client(config.base_url, config.api_key)
try:
client_info = await client.get_client(client_id)
return client_info
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch client: {str(e)}"
)
@router.post("/collect")
async def collect_artifact(
request: ArtifactCollectionRequest,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Collect an artifact from a Velociraptor client
"""
config = velociraptor_configs.get(current_user.tenant_id)
if not config:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Velociraptor not configured for this tenant"
)
client = get_velociraptor_client(config.base_url, config.api_key)
try:
result = await client.collect_artifact(
request.client_id,
request.artifact_name,
request.parameters
)
return result
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to collect artifact: {str(e)}"
)
@router.post("/hunts")
async def create_hunt(
request: HuntCreateRequest,
current_user: User = Depends(require_role(["admin"])),
db: Session = Depends(get_db)
):
"""
Create a new hunt (admin only)
"""
config = velociraptor_configs.get(current_user.tenant_id)
if not config:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Velociraptor not configured for this tenant"
)
client = get_velociraptor_client(config.base_url, config.api_key)
try:
result = await client.create_hunt(
request.hunt_name,
request.artifact_name,
request.description,
request.parameters
)
return result
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to create hunt: {str(e)}"
)
@router.get("/hunts/{hunt_id}/results")
async def get_hunt_results(
hunt_id: str,
limit: int = 1000,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Get results from a hunt
"""
config = velociraptor_configs.get(current_user.tenant_id)
if not config:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Velociraptor not configured for this tenant"
)
client = get_velociraptor_client(config.base_url, config.api_key)
try:
results = await client.get_hunt_results(hunt_id, limit=limit)
return {"results": results}
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to fetch hunt results: {str(e)}"
)

View File

@@ -1,40 +0,0 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import Optional
from app.core.database import get_db
from app.core.deps import get_current_active_user
from app.models.user import User
router = APIRouter()
class VTLookupRequest(BaseModel):
hash: str
class VTLookupResponse(BaseModel):
hash: str
malicious: Optional[bool] = None
message: str
@router.post("/lookup", response_model=VTLookupResponse)
async def virustotal_lookup(
request: VTLookupRequest,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Lookup hash in VirusTotal
Requires authentication. In a real implementation, this would call
the VirusTotal API.
"""
# Placeholder implementation
return {
"hash": request.hash,
"malicious": None,
"message": "VirusTotal integration not yet implemented"
}

121
backend/app/config.py Normal file
View File

@@ -0,0 +1,121 @@
"""Application configuration — single source of truth for all settings.
Loads from environment variables with sensible defaults for local dev.
"""
import os
from typing import Literal
from pydantic_settings import BaseSettings
from pydantic import Field
class AppConfig(BaseSettings):
"""Central configuration for the entire ThreatHunt application."""
# ── General ────────────────────────────────────────────────────────
APP_NAME: str = "ThreatHunt"
APP_VERSION: str = "0.3.0"
DEBUG: bool = Field(default=False, description="Enable debug mode")
# ── Database ───────────────────────────────────────────────────────
DATABASE_URL: str = Field(
default="sqlite+aiosqlite:///./threathunt.db",
description="Async SQLAlchemy database URL. "
"Use sqlite+aiosqlite:///./threathunt.db for local dev, "
"postgresql+asyncpg://user:pass@host/db for production.",
)
# ── CORS ───────────────────────────────────────────────────────────
ALLOWED_ORIGINS: str = Field(
default="http://localhost:3000,http://localhost:8000",
description="Comma-separated list of allowed CORS origins",
)
# ── File uploads ───────────────────────────────────────────────────
MAX_UPLOAD_SIZE_MB: int = Field(default=500, description="Max CSV upload in MB")
UPLOAD_DIR: str = Field(default="./uploads", description="Directory for uploaded files")
# ── LLM Cluster — Wile & Roadrunner ────────────────────────────────
OPENWEBUI_URL: str = Field(
default="https://ai.guapo613.beer",
description="Open WebUI cluster endpoint (OpenAI-compatible API)",
)
OPENWEBUI_API_KEY: str = Field(
default="",
description="API key for Open WebUI (if required)",
)
WILE_HOST: str = Field(
default="100.110.190.12",
description="Tailscale IP for Wile (heavy models)",
)
WILE_OLLAMA_PORT: int = Field(default=11434, description="Ollama port on Wile")
ROADRUNNER_HOST: str = Field(
default="100.110.190.11",
description="Tailscale IP for Roadrunner (fast models + vision)",
)
ROADRUNNER_OLLAMA_PORT: int = Field(
default=11434, description="Ollama port on Roadrunner"
)
# ── LLM Routing defaults ──────────────────────────────────────────
DEFAULT_FAST_MODEL: str = Field(
default="llama3.1:latest",
description="Default model for quick chat / simple queries",
)
DEFAULT_HEAVY_MODEL: str = Field(
default="llama3.1:70b-instruct-q4_K_M",
description="Default model for deep analysis / debate",
)
DEFAULT_CODE_MODEL: str = Field(
default="qwen2.5-coder:32b",
description="Default model for code / script analysis",
)
DEFAULT_VISION_MODEL: str = Field(
default="llama3.2-vision:11b",
description="Default model for image / screenshot analysis",
)
DEFAULT_EMBEDDING_MODEL: str = Field(
default="bge-m3:latest",
description="Default embedding model",
)
# ── Agent behaviour ───────────────────────────────────────────────
AGENT_MAX_TOKENS: int = Field(default=2048, description="Max tokens per agent response")
AGENT_TEMPERATURE: float = Field(default=0.3, description="LLM temperature for guidance")
AGENT_HISTORY_LENGTH: int = Field(default=10, description="Messages to keep in context")
FILTER_SENSITIVE_DATA: bool = Field(default=True, description="Redact sensitive patterns")
# ── Enrichment API keys ───────────────────────────────────────────
VIRUSTOTAL_API_KEY: str = Field(default="", description="VirusTotal API key")
ABUSEIPDB_API_KEY: str = Field(default="", description="AbuseIPDB API key")
SHODAN_API_KEY: str = Field(default="", description="Shodan API key")
# ── Auth ──────────────────────────────────────────────────────────
JWT_SECRET: str = Field(
default="CHANGE-ME-IN-PRODUCTION-USE-A-REAL-SECRET",
description="Secret for JWT signing",
)
JWT_ACCESS_TOKEN_MINUTES: int = Field(default=60, description="Access token lifetime")
JWT_REFRESH_TOKEN_DAYS: int = Field(default=7, description="Refresh token lifetime")
model_config = {"env_prefix": "TH_", "env_file": ".env", "extra": "ignore"}
@property
def cors_origins(self) -> list[str]:
return [o.strip() for o in self.ALLOWED_ORIGINS.split(",") if o.strip()]
@property
def wile_url(self) -> str:
return f"http://{self.WILE_HOST}:{self.WILE_OLLAMA_PORT}"
@property
def roadrunner_url(self) -> str:
return f"http://{self.ROADRUNNER_HOST}:{self.ROADRUNNER_OLLAMA_PORT}"
@property
def max_upload_bytes(self) -> int:
return self.MAX_UPLOAD_SIZE_MB * 1024 * 1024
settings = AppConfig()

View File

@@ -1,52 +0,0 @@
from typing import Optional, Dict, Any
from sqlalchemy.orm import Session
from fastapi import Request
from app.models.audit_log import AuditLog
def log_action(
db: Session,
user_id: Optional[int],
tenant_id: int,
action: str,
resource_type: str,
resource_id: Optional[int] = None,
details: Optional[Dict[str, Any]] = None,
request: Optional[Request] = None
):
"""
Log an action to the audit log
Args:
db: Database session
user_id: ID of user performing action (None for system actions)
tenant_id: Tenant ID
action: Action type (CREATE, READ, UPDATE, DELETE, LOGIN, etc.)
resource_type: Type of resource (user, host, case, etc.)
resource_id: ID of the resource (if applicable)
details: Additional details as JSON
request: FastAPI request object (for IP and user agent)
"""
ip_address = None
user_agent = None
if request:
ip_address = request.client.host if request.client else None
user_agent = request.headers.get("user-agent")
audit_log = AuditLog(
user_id=user_id,
tenant_id=tenant_id,
action=action,
resource_type=resource_type,
resource_id=resource_id,
details=details,
ip_address=ip_address,
user_agent=user_agent
)
db.add(audit_log)
db.commit()
return audit_log

View File

@@ -1,27 +0,0 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application settings"""
app_name: str = "VelociCompanion"
database_url: str = "postgresql://postgres:postgres@db:5432/velocicompanion"
secret_key: str = "your-secret-key-change-in-production-min-32-chars-long"
access_token_expire_minutes: int = 30
refresh_token_expire_days: int = 30
algorithm: str = "HS256"
# Email settings (for password reset)
smtp_host: str = "localhost"
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
from_email: str = "noreply@velocicompanion.com"
# WebSocket settings
ws_enabled: bool = True
class Config:
env_file = ".env"
settings = Settings()

View File

@@ -1,19 +0,0 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
engine = create_engine(settings.database_url)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
"""Dependency for getting database session"""
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -1,108 +0,0 @@
from typing import List, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import verify_token
from app.models.user import User
# OAuth2 scheme for token extraction
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db)
) -> User:
"""
Extract and validate JWT from Authorization header
Args:
token: JWT token from Authorization header
db: Database session
Returns:
Current user object
Raises:
HTTPException: If token is invalid or user not found
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
# Verify and decode token
payload = verify_token(token)
if payload is None:
raise credentials_exception
user_id: Optional[int] = payload.get("sub")
if user_id is None:
raise credentials_exception
# Get user from database
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise credentials_exception
return user
async def get_current_active_user(
current_user: User = Depends(get_current_user)
) -> User:
"""
Ensure user is active
Args:
current_user: Current user from get_current_user
Returns:
Active user object
Raises:
HTTPException: If user is inactive
"""
if not current_user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Inactive user"
)
return current_user
def require_role(allowed_roles: List[str]):
"""
Role-based access control dependency factory
Args:
allowed_roles: List of roles that are allowed to access the endpoint
Returns:
Dependency function that checks user role
"""
async def role_checker(current_user: User = Depends(get_current_active_user)) -> User:
if current_user.role not in allowed_roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Insufficient permissions"
)
return current_user
return role_checker
async def get_tenant_id(current_user: User = Depends(get_current_active_user)) -> int:
"""
Extract tenant_id from current user for scoping queries
Args:
current_user: Current active user
Returns:
Tenant ID
"""
return current_user.tenant_id

View File

@@ -1,263 +0,0 @@
"""
Job Scheduler
Manages job distribution across GPU nodes based on availability and load.
"""
from typing import Dict, Any, List, Optional
from dataclasses import dataclass
from enum import Enum
import asyncio
class NodeStatus(Enum):
"""Status of GPU node"""
AVAILABLE = "available"
BUSY = "busy"
OFFLINE = "offline"
@dataclass
class GPUNode:
"""Represents a GPU compute node"""
node_id: str
hostname: str
port: int
vram_total_gb: int
vram_used_gb: int
compute_utilization: float # 0.0 to 1.0
status: NodeStatus
models_loaded: List[str]
@property
def vram_available_gb(self) -> int:
"""Calculate available VRAM"""
return self.vram_total_gb - self.vram_used_gb
@property
def is_available(self) -> bool:
"""Check if node is available for work"""
return self.status == NodeStatus.AVAILABLE and self.compute_utilization < 0.9
@dataclass
class Job:
"""Represents an LLM job"""
job_id: str
model: str
priority: int
estimated_vram_gb: int
requires_parallel: bool
requires_chaining: bool
payload: Dict[str, Any]
class JobScheduler:
"""
Job Scheduler - Manages distribution of LLM jobs across GPU nodes
Decides:
- Which GB10 device is available
- GPU load (VRAM, compute utilization)
- Whether to parallelize across both nodes
- Whether job requires serial reasoning (chained)
"""
def __init__(self):
"""Initialize job scheduler"""
self.nodes: Dict[str, GPUNode] = {}
self.job_queue: List[Job] = []
self._initialize_nodes()
def _initialize_nodes(self):
"""Initialize GPU node configuration"""
# GB10 Node 1
self.nodes["gb10-node-1"] = GPUNode(
node_id="gb10-node-1",
hostname="gb10-node-1",
port=8001,
vram_total_gb=80,
vram_used_gb=0,
compute_utilization=0.0,
status=NodeStatus.AVAILABLE,
models_loaded=["deepseek", "qwen72"]
)
# GB10 Node 2
self.nodes["gb10-node-2"] = GPUNode(
node_id="gb10-node-2",
hostname="gb10-node-2",
port=8001,
vram_total_gb=80,
vram_used_gb=0,
compute_utilization=0.0,
status=NodeStatus.AVAILABLE,
models_loaded=["phi4", "qwen-coder", "llama31", "granite-guardian"]
)
def get_available_nodes(self) -> List[GPUNode]:
"""Get list of available nodes"""
return [node for node in self.nodes.values() if node.is_available]
def find_best_node(self, job: Job) -> Optional[GPUNode]:
"""
Find best node for a job based on availability and requirements
Args:
job: Job to schedule
Returns:
Best GPU node or None if unavailable
"""
available_nodes = self.get_available_nodes()
# Filter nodes that have required model loaded
suitable_nodes = [
node for node in available_nodes
if job.model in node.models_loaded
and node.vram_available_gb >= job.estimated_vram_gb
]
if not suitable_nodes:
return None
# Sort by compute utilization (prefer less loaded nodes)
suitable_nodes.sort(key=lambda n: n.compute_utilization)
return suitable_nodes[0]
def should_parallelize(self, job: Job) -> bool:
"""
Determine if job should be parallelized across multiple nodes
Args:
job: Job to evaluate
Returns:
True if should parallelize
"""
available_nodes = self.get_available_nodes()
# Need at least 2 nodes for parallelization
if len(available_nodes) < 2:
return False
# Job explicitly requires parallel execution
if job.requires_parallel:
return True
# High priority jobs with multiple available nodes
if job.priority >= 1 and len(available_nodes) >= 2:
return True
return False
def get_parallel_nodes(self, job: Job) -> List[GPUNode]:
"""
Get nodes for parallel execution
Args:
job: Job to parallelize
Returns:
List of nodes to use
"""
available_nodes = self.get_available_nodes()
# Filter nodes with required model and sufficient VRAM
suitable_nodes = [
node for node in available_nodes
if job.model in node.models_loaded
and node.vram_available_gb >= job.estimated_vram_gb
]
# Return up to 2 nodes for parallel execution
return suitable_nodes[:2]
async def schedule_job(self, job: Job) -> Dict[str, Any]:
"""
Schedule a job for execution
Args:
job: Job to schedule
Returns:
Scheduling decision with node assignments
"""
# Check if job should be parallelized
if self.should_parallelize(job):
nodes = self.get_parallel_nodes(job)
if len(nodes) >= 2:
return {
"job_id": job.job_id,
"execution_mode": "parallel",
"nodes": [
{"node_id": node.node_id, "endpoint": f"http://{node.hostname}:{node.port}/{job.model}"}
for node in nodes
],
"estimated_time": "distributed"
}
# Serial execution on single node
node = self.find_best_node(job)
if not node:
# Add to queue if no nodes available
self.job_queue.append(job)
return {
"job_id": job.job_id,
"execution_mode": "queued",
"status": "waiting_for_resources",
"queue_position": len(self.job_queue)
}
return {
"job_id": job.job_id,
"execution_mode": "serial" if job.requires_chaining else "single",
"node": {
"node_id": node.node_id,
"endpoint": f"http://{node.hostname}:{node.port}/{job.model}"
},
"vram_allocated_gb": job.estimated_vram_gb,
"estimated_time": "standard"
}
def update_node_status(
self,
node_id: str,
vram_used_gb: Optional[int] = None,
compute_utilization: Optional[float] = None,
status: Optional[NodeStatus] = None
):
"""
Update node status metrics
Args:
node_id: Node to update
vram_used_gb: Current VRAM usage
compute_utilization: Current compute utilization (0.0-1.0)
status: Node status
"""
if node_id not in self.nodes:
return
node = self.nodes[node_id]
if vram_used_gb is not None:
node.vram_used_gb = vram_used_gb
if compute_utilization is not None:
node.compute_utilization = compute_utilization
if status is not None:
node.status = status
def get_job_scheduler() -> JobScheduler:
"""
Factory function to create job scheduler
Returns:
Configured JobScheduler instance
"""
return JobScheduler()

View File

@@ -1,211 +0,0 @@
"""
LLM Pool Manager
Manages pool of LLM endpoints with OpenAI-compatible interface.
"""
from typing import Dict, Any, List, Optional
import httpx
from dataclasses import dataclass
@dataclass
class LLMEndpoint:
"""Represents an LLM endpoint"""
model_name: str
node_id: str
base_url: str
is_available: bool = True
@property
def endpoint_url(self) -> str:
"""Get full endpoint URL"""
return f"{self.base_url}/{self.model_name}"
class LLMPoolManager:
"""
Pool of LLM Endpoints
Each model is exposed via an OpenAI-compatible endpoint:
- http://gb10-node-1:8001/deepseek
- http://gb10-node-1:8001/qwen72
- http://gb10-node-2:8001/phi4
- http://gb10-node-2:8001/qwen-coder
- http://gb10-node-2:8001/llama31
- http://gb10-node-2:8001/granite-guardian
"""
def __init__(self):
"""Initialize LLM pool"""
self.endpoints: Dict[str, LLMEndpoint] = {}
self._initialize_endpoints()
def _initialize_endpoints(self):
"""Initialize all LLM endpoints"""
# GB10 Node 1 endpoints
self.endpoints["deepseek"] = LLMEndpoint(
model_name="deepseek",
node_id="gb10-node-1",
base_url="http://gb10-node-1:8001"
)
self.endpoints["qwen72"] = LLMEndpoint(
model_name="qwen72",
node_id="gb10-node-1",
base_url="http://gb10-node-1:8001"
)
# GB10 Node 2 endpoints
self.endpoints["phi4"] = LLMEndpoint(
model_name="phi4",
node_id="gb10-node-2",
base_url="http://gb10-node-2:8001"
)
self.endpoints["qwen-coder"] = LLMEndpoint(
model_name="qwen-coder",
node_id="gb10-node-2",
base_url="http://gb10-node-2:8001"
)
self.endpoints["llama31"] = LLMEndpoint(
model_name="llama31",
node_id="gb10-node-2",
base_url="http://gb10-node-2:8001"
)
self.endpoints["granite-guardian"] = LLMEndpoint(
model_name="granite-guardian",
node_id="gb10-node-2",
base_url="http://gb10-node-2:8001"
)
def get_endpoint(self, model_name: str) -> Optional[LLMEndpoint]:
"""
Get endpoint for a specific model
Args:
model_name: Name of the model
Returns:
LLMEndpoint or None if not found
"""
return self.endpoints.get(model_name)
async def call_model(
self,
model_name: str,
prompt: str,
parameters: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Call an LLM model via its endpoint
Args:
model_name: Name of the model
prompt: Input prompt
parameters: Optional model parameters
Returns:
Model response
"""
endpoint = self.get_endpoint(model_name)
if not endpoint:
return {
"error": f"Model {model_name} not found",
"available_models": list(self.endpoints.keys())
}
if not endpoint.is_available:
return {
"error": f"Endpoint {model_name} is currently unavailable",
"status": "offline"
}
# Prepare OpenAI-compatible request
payload = {
"model": model_name,
"messages": [
{"role": "user", "content": prompt}
],
"temperature": parameters.get("temperature", 0.7) if parameters else 0.7,
"max_tokens": parameters.get("max_tokens", 2048) if parameters else 2048
}
try:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(
f"{endpoint.endpoint_url}/v1/chat/completions",
json=payload
)
response.raise_for_status()
return response.json()
except httpx.HTTPError as e:
return {
"error": f"Failed to call {model_name}",
"details": str(e),
"endpoint": endpoint.endpoint_url
}
async def call_multiple_models(
self,
model_names: List[str],
prompt: str,
parameters: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Call multiple models in parallel
Args:
model_names: List of model names
prompt: Input prompt
parameters: Optional model parameters
Returns:
Combined results from all models
"""
import asyncio
tasks = [
self.call_model(model, prompt, parameters)
for model in model_names
]
results = await asyncio.gather(*tasks, return_exceptions=True)
return {
"models": model_names,
"results": [
{"model": model, "response": result}
for model, result in zip(model_names, results)
]
}
def list_available_models(self) -> List[Dict[str, Any]]:
"""
List all available models
Returns:
List of model information
"""
return [
{
"model_name": endpoint.model_name,
"node_id": endpoint.node_id,
"endpoint_url": endpoint.endpoint_url,
"is_available": endpoint.is_available
}
for endpoint in self.endpoints.values()
]
def get_llm_pool() -> LLMPoolManager:
"""
Factory function to create LLM pool manager
Returns:
Configured LLMPoolManager instance
"""
return LLMPoolManager()

View File

@@ -1,187 +0,0 @@
"""
LLM Router Agent
Routes requests to appropriate LLM models based on task classification.
"""
from typing import Dict, Any, Optional, List
from enum import Enum
import httpx
class TaskType(Enum):
"""Types of tasks for LLM routing"""
GENERAL_REASONING = "general_reasoning" # DeepSeek
MULTILINGUAL = "multilingual" # Qwen / Aya
STRUCTURED_PARSING = "structured_parsing" # Phi-4
RULE_GENERATION = "rule_generation" # Qwen-Coder
ADVERSARIAL_REASONING = "adversarial_reasoning" # LLaMA 3.1
CLASSIFICATION = "classification" # Granite Guardian
class LLMRouterAgent:
"""
Router Agent - Interprets incoming requests and routes to appropriate LLM
This agent classifies the incoming request and determines which specialized
LLM should handle it based on the task type.
"""
def __init__(self, policy_config: Optional[Dict[str, Any]] = None):
"""
Initialize router agent
Args:
policy_config: Optional routing policy configuration
"""
self.policy_config = policy_config or {}
self.routing_rules = self._initialize_routing_rules()
def _initialize_routing_rules(self) -> Dict[TaskType, Dict[str, Any]]:
"""Initialize routing rules for each task type"""
return {
TaskType.GENERAL_REASONING: {
"model": "deepseek",
"endpoint": "deepseek",
"priority": 1,
"description": "General reasoning and complex analysis"
},
TaskType.MULTILINGUAL: {
"model": "qwen72",
"endpoint": "qwen72",
"priority": 2,
"description": "Multilingual translation and analysis"
},
TaskType.STRUCTURED_PARSING: {
"model": "phi4",
"endpoint": "phi4",
"priority": 3,
"description": "Structured data parsing and extraction"
},
TaskType.RULE_GENERATION: {
"model": "qwen-coder",
"endpoint": "qwen-coder",
"priority": 2,
"description": "Code and rule generation"
},
TaskType.ADVERSARIAL_REASONING: {
"model": "llama31",
"endpoint": "llama31",
"priority": 1,
"description": "Adversarial threat analysis"
},
TaskType.CLASSIFICATION: {
"model": "granite-guardian",
"endpoint": "granite-guardian",
"priority": 4,
"description": "Pure classification tasks"
}
}
def classify_request(self, request: Dict[str, Any]) -> TaskType:
"""
Classify incoming request to determine task type
Args:
request: Request containing prompt and metadata
Returns:
Classified task type
"""
prompt = request.get("prompt", "").lower()
task_hints = request.get("task_hints", [])
# Classification logic based on keywords and hints
if any(hint in task_hints for hint in ["translate", "multilingual", "language"]):
return TaskType.MULTILINGUAL
if any(hint in task_hints for hint in ["parse", "extract", "structure"]):
return TaskType.STRUCTURED_PARSING
if any(hint in task_hints for hint in ["code", "rule", "generate", "script"]):
return TaskType.RULE_GENERATION
if any(hint in task_hints for hint in ["threat", "adversary", "attack", "malicious"]):
return TaskType.ADVERSARIAL_REASONING
if any(hint in task_hints for hint in ["classify", "categorize", "label"]):
return TaskType.CLASSIFICATION
# Default to general reasoning
return TaskType.GENERAL_REASONING
def route_request(self, request: Dict[str, Any]) -> Dict[str, Any]:
"""
Route request to appropriate LLM endpoint
Args:
request: Request to route
Returns:
Routing decision with endpoint and model info
"""
task_type = self.classify_request(request)
routing_rule = self.routing_rules[task_type]
return {
"task_type": task_type.value,
"model": routing_rule["model"],
"endpoint": routing_rule["endpoint"],
"priority": routing_rule["priority"],
"description": routing_rule["description"],
"requires_parallel": request.get("requires_parallel", False),
"requires_chaining": request.get("requires_chaining", False)
}
def should_parallelize(self, request: Dict[str, Any]) -> bool:
"""
Determine if request should be parallelized across multiple nodes
Args:
request: Request to evaluate
Returns:
True if should be parallelized
"""
# Large batch requests
if request.get("batch_size", 1) > 10:
return True
# Explicit parallel flag
if request.get("requires_parallel", False):
return True
return False
def requires_serial_chaining(self, request: Dict[str, Any]) -> bool:
"""
Determine if request requires serial reasoning (chained operations)
Args:
request: Request to evaluate
Returns:
True if requires chaining
"""
# Complex multi-step reasoning
if request.get("requires_chaining", False):
return True
# Multiple dependent operations
if len(request.get("operations", [])) > 1:
return True
return False
def get_llm_router(policy_config: Optional[Dict[str, Any]] = None) -> LLMRouterAgent:
"""
Factory function to create LLM router agent
Args:
policy_config: Optional routing policy configuration
Returns:
Configured LLMRouterAgent instance
"""
return LLMRouterAgent(policy_config)

View File

@@ -1,259 +0,0 @@
"""
Merger Agent
Combines and synthesizes results from multiple LLM models.
"""
from typing import Dict, Any, List, Optional
from enum import Enum
class MergeStrategy(Enum):
"""Strategies for merging LLM results"""
CONSENSUS = "consensus" # Take majority vote
WEIGHTED = "weighted" # Weight by model confidence
CONCATENATE = "concatenate" # Combine all outputs
BEST_QUALITY = "best_quality" # Select highest quality response
ENSEMBLE = "ensemble" # Ensemble multiple results
class MergerAgent:
"""
Merger Agent - Combines results from multiple LLM executions
When multiple models process the same or related requests,
this agent intelligently merges their outputs into a coherent response.
"""
def __init__(self, default_strategy: MergeStrategy = MergeStrategy.CONSENSUS):
"""
Initialize merger agent
Args:
default_strategy: Default merging strategy
"""
self.default_strategy = default_strategy
def merge_results(
self,
results: List[Dict[str, Any]],
strategy: Optional[MergeStrategy] = None
) -> Dict[str, Any]:
"""
Merge multiple LLM results using specified strategy
Args:
results: List of results from different models
strategy: Merging strategy (uses default if not specified)
Returns:
Merged result
"""
if not results:
return {"error": "No results to merge"}
if len(results) == 1:
return results[0]
merge_strategy = strategy or self.default_strategy
if merge_strategy == MergeStrategy.CONSENSUS:
return self._merge_consensus(results)
elif merge_strategy == MergeStrategy.WEIGHTED:
return self._merge_weighted(results)
elif merge_strategy == MergeStrategy.CONCATENATE:
return self._merge_concatenate(results)
elif merge_strategy == MergeStrategy.BEST_QUALITY:
return self._merge_best_quality(results)
elif merge_strategy == MergeStrategy.ENSEMBLE:
return self._merge_ensemble(results)
else:
return self._merge_consensus(results)
def _merge_consensus(self, results: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Merge by consensus - take majority vote or most common response
Args:
results: List of results
Returns:
Consensus result
"""
# For classification tasks, take majority vote
if all("classification" in r for r in results):
classifications = [r["classification"] for r in results]
most_common = max(set(classifications), key=classifications.count)
return {
"strategy": "consensus",
"result": most_common,
"confidence": classifications.count(most_common) / len(classifications),
"votes": dict((k, classifications.count(k)) for k in set(classifications))
}
# For text generation, use first high-quality result
valid_results = [r for r in results if "response" in r and r["response"]]
if valid_results:
return {
"strategy": "consensus",
"result": valid_results[0]["response"],
"num_models": len(results)
}
return {"strategy": "consensus", "result": None}
def _merge_weighted(self, results: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Merge by weighted average based on model confidence
Args:
results: List of results with confidence scores
Returns:
Weighted result
"""
# Extract results with confidence scores
weighted_results = [
(r.get("response", ""), r.get("confidence", 0.5))
for r in results
]
# Sort by confidence
weighted_results.sort(key=lambda x: x[1], reverse=True)
return {
"strategy": "weighted",
"result": weighted_results[0][0],
"confidence": weighted_results[0][1],
"all_results": [
{"response": resp, "confidence": conf}
for resp, conf in weighted_results
]
}
def _merge_concatenate(self, results: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Concatenate all results
Args:
results: List of results
Returns:
Concatenated result
"""
responses = [
r.get("response", "") for r in results if r.get("response")
]
return {
"strategy": "concatenate",
"result": "\n\n---\n\n".join(responses),
"num_responses": len(responses)
}
def _merge_best_quality(self, results: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Select the highest quality response
Args:
results: List of results
Returns:
Best quality result
"""
# Score responses by length and presence of key indicators
scored_results = []
for r in results:
response = r.get("response", "")
if not response:
continue
score = 0
score += len(response) / 100 # Longer responses get higher score
score += response.count(".") * 0.5 # More complete sentences
score += response.count("\n") * 0.3 # Better formatting
scored_results.append((response, score, r))
if not scored_results:
return {"strategy": "best_quality", "result": None}
scored_results.sort(key=lambda x: x[1], reverse=True)
best_response, best_score, best_result = scored_results[0]
return {
"strategy": "best_quality",
"result": best_response,
"quality_score": best_score,
"model": best_result.get("model", "unknown")
}
def _merge_ensemble(self, results: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Ensemble multiple results by combining their insights
Args:
results: List of results
Returns:
Ensemble result
"""
# Collect unique insights from all models
all_responses = [r.get("response", "") for r in results if r.get("response")]
# Create ensemble summary
ensemble_summary = {
"strategy": "ensemble",
"num_models": len(results),
"individual_results": [
{
"model": r.get("model", "unknown"),
"response": r.get("response", ""),
"confidence": r.get("confidence", 0.5)
}
for r in results
],
"synthesized_result": self._synthesize_insights(all_responses)
}
return ensemble_summary
def _synthesize_insights(self, responses: List[str]) -> str:
"""
Synthesize insights from multiple responses
Args:
responses: List of response strings
Returns:
Synthesized summary
"""
if not responses:
return ""
# Simple synthesis - in production, use another LLM to synthesize
unique_points = []
for response in responses:
sentences = response.split(". ")
for sentence in sentences:
if sentence and sentence not in unique_points:
unique_points.append(sentence)
return ". ".join(unique_points[:10]) # Top 10 insights
def get_merger_agent(
default_strategy: MergeStrategy = MergeStrategy.CONSENSUS
) -> MergerAgent:
"""
Factory function to create merger agent
Args:
default_strategy: Default merging strategy
Returns:
Configured MergerAgent instance
"""
return MergerAgent(default_strategy)

View File

@@ -1,165 +0,0 @@
"""
Playbook Execution Engine
Executes automated response playbooks based on triggers.
"""
from typing import Dict, Any, List
from datetime import datetime, timezone
import asyncio
class PlaybookEngine:
"""Engine for executing playbooks"""
def __init__(self):
"""Initialize playbook engine"""
self.actions_registry = {
"send_notification": self._action_send_notification,
"create_case": self._action_create_case,
"isolate_host": self._action_isolate_host,
"collect_artifact": self._action_collect_artifact,
"block_ip": self._action_block_ip,
"send_email": self._action_send_email,
}
async def execute_playbook(
self,
playbook: Dict[str, Any],
context: Dict[str, Any]
) -> Dict[str, Any]:
"""
Execute a playbook
Args:
playbook: Playbook definition
context: Execution context with relevant data
Returns:
Execution result
"""
results = []
errors = []
actions = playbook.get("actions", [])
for action in actions:
action_type = action.get("type")
action_params = action.get("params", {})
try:
# Get action handler
handler = self.actions_registry.get(action_type)
if not handler:
errors.append(f"Unknown action type: {action_type}")
continue
# Execute action
result = await handler(action_params, context)
results.append({
"action": action_type,
"status": "success",
"result": result
})
except Exception as e:
errors.append(f"Error in action {action_type}: {str(e)}")
results.append({
"action": action_type,
"status": "failed",
"error": str(e)
})
return {
"status": "completed" if not errors else "completed_with_errors",
"results": results,
"errors": errors
}
async def _action_send_notification(
self,
params: Dict[str, Any],
context: Dict[str, Any]
) -> Dict[str, Any]:
"""Send a notification"""
# In production, this would create a notification in the database
# and push it via WebSocket
return {
"notification_sent": True,
"message": params.get("message", "Playbook notification")
}
async def _action_create_case(
self,
params: Dict[str, Any],
context: Dict[str, Any]
) -> Dict[str, Any]:
"""Create a new case"""
# In production, this would create a case in the database
return {
"case_created": True,
"case_title": params.get("title", "Automated Case"),
"case_id": "placeholder_id"
}
async def _action_isolate_host(
self,
params: Dict[str, Any],
context: Dict[str, Any]
) -> Dict[str, Any]:
"""Isolate a host"""
# In production, this would call Velociraptor or other tools
# to isolate the host from the network
host_id = params.get("host_id")
return {
"host_isolated": True,
"host_id": host_id
}
async def _action_collect_artifact(
self,
params: Dict[str, Any],
context: Dict[str, Any]
) -> Dict[str, Any]:
"""Collect an artifact from a host"""
# In production, this would trigger Velociraptor collection
return {
"collection_started": True,
"artifact": params.get("artifact_name"),
"client_id": params.get("client_id")
}
async def _action_block_ip(
self,
params: Dict[str, Any],
context: Dict[str, Any]
) -> Dict[str, Any]:
"""Block an IP address"""
# In production, this would update firewall rules
ip_address = params.get("ip_address")
return {
"ip_blocked": True,
"ip_address": ip_address
}
async def _action_send_email(
self,
params: Dict[str, Any],
context: Dict[str, Any]
) -> Dict[str, Any]:
"""Send an email"""
# In production, this would send an actual email
return {
"email_sent": True,
"to": params.get("to"),
"subject": params.get("subject")
}
def get_playbook_engine() -> PlaybookEngine:
"""
Factory function to create playbook engine
Returns:
Configured PlaybookEngine instance
"""
return PlaybookEngine()

View File

@@ -1,120 +0,0 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
import secrets
import pyotp
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import settings
# Password hashing context
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against a hashed password"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hash a password using bcrypt"""
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""
Create a JWT access token
Args:
data: Dictionary containing user_id, tenant_id, role
expires_delta: Optional expiration time delta
Returns:
Encoded JWT token
"""
to_encode = data.copy()
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
return encoded_jwt
def verify_token(token: str) -> Optional[dict]:
"""
Verify and decode a JWT token
Args:
token: JWT token string
Returns:
Decoded token payload or None if invalid
"""
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
return payload
except JWTError:
return None
def create_refresh_token() -> str:
"""
Create a secure random refresh token
Returns:
Random token string
"""
return secrets.token_urlsafe(32)
def create_reset_token() -> str:
"""
Create a secure random password reset token
Returns:
Random token string
"""
return secrets.token_urlsafe(32)
def generate_totp_secret() -> str:
"""
Generate a TOTP secret for 2FA
Returns:
Base32 encoded secret
"""
return pyotp.random_base32()
def verify_totp(secret: str, code: str) -> bool:
"""
Verify a TOTP code
Args:
secret: TOTP secret
code: 6-digit code from authenticator app
Returns:
True if code is valid
"""
totp = pyotp.TOTP(secret)
return totp.verify(code, valid_window=1)
def get_totp_uri(secret: str, username: str) -> str:
"""
Get TOTP provisioning URI for QR code
Args:
secret: TOTP secret
username: User's username
Returns:
otpauth:// URI
"""
totp = pyotp.TOTP(secret)
return totp.provisioning_uri(name=username, issuer_name="VelociCompanion")

View File

@@ -1,198 +0,0 @@
"""
Threat Intelligence and Machine Learning Module
This module provides threat scoring, anomaly detection, and predictive analytics.
"""
from typing import Dict, Any, List, Optional
import random # For demo purposes - would use actual ML models in production
class ThreatAnalyzer:
"""Analyzes threats using ML models and heuristics"""
def __init__(self, model_version: str = "1.0"):
"""
Initialize threat analyzer
Args:
model_version: Version of ML models to use
"""
self.model_version = model_version
def analyze_host(self, host_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Analyze a host for threats
Args:
host_data: Host information and telemetry
Returns:
Dictionary with threat score and indicators
"""
# In production, this would use ML models
# For demo, using simple heuristics
score = 0.0
confidence = 0.8
indicators = []
# Check for suspicious patterns
hostname = host_data.get("hostname", "")
if "temp" in hostname.lower() or "test" in hostname.lower():
score += 0.2
indicators.append({
"type": "suspicious_hostname",
"description": "Hostname contains suspicious keywords",
"severity": "low"
})
# Check metadata for anomalies
metadata = host_data.get("host_metadata", {})
if metadata:
# Check for unusual processes, connections, etc.
if "suspicious_process" in str(metadata):
score += 0.5
indicators.append({
"type": "suspicious_process",
"description": "Unusual process detected",
"severity": "high"
})
# Normalize score
score = min(score, 1.0)
return {
"score": score,
"confidence": confidence,
"threat_type": self._classify_threat(score),
"indicators": indicators,
"ml_model_version": self.model_version
}
def analyze_artifact(self, artifact_data: Dict[str, Any]) -> Dict[str, Any]:
"""
Analyze an artifact for threats
Args:
artifact_data: Artifact information
Returns:
Dictionary with threat score and indicators
"""
score = 0.0
confidence = 0.7
indicators = []
artifact_type = artifact_data.get("artifact_type", "")
value = artifact_data.get("value", "")
# Hash analysis
if artifact_type == "hash":
# In production, check against threat intelligence feeds
if len(value) == 32: # MD5
score += 0.3
indicators.append({
"type": "weak_hash",
"description": "MD5 hashes are considered weak",
"severity": "low"
})
# IP analysis
elif artifact_type == "ip":
# Check if IP is in known malicious ranges
if value.startswith("10.") or value.startswith("192.168."):
score += 0.1 # Private IP, lower risk
else:
score += 0.4 # Public IP, higher scrutiny
indicators.append({
"type": "public_ip",
"description": "Communication with public IP",
"severity": "medium"
})
# Domain analysis
elif artifact_type == "domain":
# Check for suspicious TLDs or patterns
suspicious_tlds = [".ru", ".cn", ".tk", ".xyz"]
if any(value.endswith(tld) for tld in suspicious_tlds):
score += 0.6
indicators.append({
"type": "suspicious_tld",
"description": f"Domain uses potentially suspicious TLD",
"severity": "high"
})
score = min(score, 1.0)
return {
"score": score,
"confidence": confidence,
"threat_type": self._classify_threat(score),
"indicators": indicators,
"ml_model_version": self.model_version
}
def detect_anomalies(
self,
historical_data: List[Dict[str, Any]],
current_data: Dict[str, Any]
) -> Dict[str, Any]:
"""
Detect anomalies in current data compared to historical baseline
Args:
historical_data: Historical baseline data
current_data: Current data to analyze
Returns:
Anomaly detection results
"""
# Simple anomaly detection based on statistical deviation
# In production, use more sophisticated methods
anomalies = []
score = 0.0
# Compare metrics
if historical_data and len(historical_data) >= 3:
# Calculate baseline
# This is a simplified example
anomalies.append({
"type": "behavioral_anomaly",
"description": "Behavior deviates from baseline",
"severity": "medium"
})
score = 0.5
return {
"is_anomaly": score > 0.4,
"anomaly_score": score,
"anomalies": anomalies
}
def _classify_threat(self, score: float) -> str:
"""Classify threat based on score"""
if score >= 0.8:
return "critical"
elif score >= 0.6:
return "high"
elif score >= 0.4:
return "medium"
elif score >= 0.2:
return "low"
else:
return "benign"
def get_threat_analyzer(model_version: str = "1.0") -> ThreatAnalyzer:
"""
Factory function to create threat analyzer
Args:
model_version: Version of ML models
Returns:
Configured ThreatAnalyzer instance
"""
return ThreatAnalyzer(model_version)

View File

@@ -1,194 +0,0 @@
"""
Velociraptor API Client
This module provides integration with Velociraptor servers for artifact
collection, hunt management, and client operations.
"""
from typing import List, Dict, Any, Optional
import httpx
from datetime import datetime
class VelociraptorClient:
"""Client for interacting with Velociraptor API"""
def __init__(self, base_url: str, api_key: str):
"""
Initialize Velociraptor client
Args:
base_url: Base URL of Velociraptor server
api_key: API key for authentication
"""
self.base_url = base_url.rstrip('/')
self.api_key = api_key
self.headers = {
"Authorization": f"******",
"Content-Type": "application/json"
}
async def list_clients(self, limit: int = 100) -> List[Dict[str, Any]]:
"""
List all clients
Args:
limit: Maximum number of clients to return
Returns:
List of client information dictionaries
"""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.base_url}/api/v1/clients",
headers=self.headers,
params={"count": limit}
)
response.raise_for_status()
return response.json()
async def get_client(self, client_id: str) -> Dict[str, Any]:
"""
Get information about a specific client
Args:
client_id: Client ID
Returns:
Client information dictionary
"""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.base_url}/api/v1/clients/{client_id}",
headers=self.headers
)
response.raise_for_status()
return response.json()
async def collect_artifact(
self,
client_id: str,
artifact_name: str,
parameters: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Collect an artifact from a client
Args:
client_id: Client ID
artifact_name: Name of artifact to collect
parameters: Optional parameters for artifact collection
Returns:
Collection flow information
"""
payload = {
"client_id": client_id,
"artifacts": [artifact_name],
"parameters": parameters or {}
}
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.base_url}/api/v1/flows",
headers=self.headers,
json=payload
)
response.raise_for_status()
return response.json()
async def create_hunt(
self,
hunt_name: str,
artifact_name: str,
description: str,
parameters: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Create a new hunt
Args:
hunt_name: Name of the hunt
artifact_name: Artifact to collect
description: Hunt description
parameters: Optional parameters for artifact
Returns:
Hunt information
"""
payload = {
"hunt_name": hunt_name,
"description": description,
"artifacts": [artifact_name],
"parameters": parameters or {}
}
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.base_url}/api/v1/hunts",
headers=self.headers,
json=payload
)
response.raise_for_status()
return response.json()
async def get_hunt_results(
self,
hunt_id: str,
limit: int = 1000
) -> List[Dict[str, Any]]:
"""
Get results from a hunt
Args:
hunt_id: Hunt ID
limit: Maximum number of results to return
Returns:
List of hunt results
"""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.base_url}/api/v1/hunts/{hunt_id}/results",
headers=self.headers,
params={"count": limit}
)
response.raise_for_status()
return response.json()
async def get_flow_results(
self,
client_id: str,
flow_id: str
) -> List[Dict[str, Any]]:
"""
Get results from a flow
Args:
client_id: Client ID
flow_id: Flow ID
Returns:
List of flow results
"""
async with httpx.AsyncClient() as client:
response = await client.get(
f"{self.base_url}/api/v1/clients/{client_id}/flows/{flow_id}/results",
headers=self.headers
)
response.raise_for_status()
return response.json()
def get_velociraptor_client(base_url: str, api_key: str) -> VelociraptorClient:
"""
Factory function to create Velociraptor client
Args:
base_url: Base URL of Velociraptor server
api_key: API key for authentication
Returns:
Configured VelociraptorClient instance
"""
return VelociraptorClient(base_url, api_key)

View File

@@ -0,0 +1,12 @@
"""Database package."""
from .engine import Base, get_db, init_db, dispose_db, engine, async_session_factory
__all__ = [
"Base",
"get_db",
"init_db",
"dispose_db",
"engine",
"async_session_factory",
]

75
backend/app/db/engine.py Normal file
View File

@@ -0,0 +1,75 @@
"""Database engine, session factory, and base model.
Uses async SQLAlchemy with aiosqlite for local dev and asyncpg for production PostgreSQL.
"""
from sqlalchemy import event
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
_is_sqlite = settings.DATABASE_URL.startswith("sqlite")
_engine_kwargs: dict = dict(
echo=settings.DEBUG,
future=True,
)
if _is_sqlite:
_engine_kwargs["connect_args"] = {"timeout": 30}
_engine_kwargs["pool_size"] = 1
_engine_kwargs["max_overflow"] = 0
engine = create_async_engine(settings.DATABASE_URL, **_engine_kwargs)
@event.listens_for(engine.sync_engine, "connect")
def _set_sqlite_pragmas(dbapi_conn, connection_record):
"""Enable WAL mode and tune busy-timeout for SQLite connections."""
if _is_sqlite:
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.close()
async_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
"""Base class for all ORM models."""
pass
async def get_db() -> AsyncSession: # type: ignore[misc]
"""FastAPI dependency that yields an async DB session."""
async with async_session_factory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
async def init_db() -> None:
"""Create all tables (for dev / first-run). In production use Alembic."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def dispose_db() -> None:
"""Dispose of the engine connection pool."""
await engine.dispose()

402
backend/app/db/models.py Normal file
View File

@@ -0,0 +1,402 @@
"""SQLAlchemy ORM models for ThreatHunt.
All persistent entities: datasets, hunts, conversations, annotations,
hypotheses, enrichment results, users, and AI analysis tables.
"""
import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import (
Boolean,
DateTime,
Float,
ForeignKey,
Integer,
String,
Text,
JSON,
Index,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .engine import Base
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _new_id() -> str:
return uuid.uuid4().hex
# -- Users ---
class User(Base):
__tablename__ = "users"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_new_id)
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False, index=True)
email: Mapped[str] = mapped_column(String(256), unique=True, nullable=False)
hashed_password: Mapped[str] = mapped_column(String(256), nullable=False)
role: Mapped[str] = mapped_column(String(16), default="analyst")
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
hunts: Mapped[list["Hunt"]] = relationship(back_populates="owner", lazy="selectin")
annotations: Mapped[list["Annotation"]] = relationship(back_populates="author", lazy="selectin")
# -- Hunts ---
class Hunt(Base):
__tablename__ = "hunts"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_new_id)
name: Mapped[str] = mapped_column(String(256), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(String(32), default="active")
owner_id: Mapped[Optional[str]] = mapped_column(
String(32), ForeignKey("users.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
)
owner: Mapped[Optional["User"]] = relationship(back_populates="hunts", lazy="selectin")
datasets: Mapped[list["Dataset"]] = relationship(back_populates="hunt", lazy="selectin")
conversations: Mapped[list["Conversation"]] = relationship(back_populates="hunt", lazy="selectin")
hypotheses: Mapped[list["Hypothesis"]] = relationship(back_populates="hunt", lazy="selectin")
host_profiles: Mapped[list["HostProfile"]] = relationship(back_populates="hunt", lazy="noload")
reports: Mapped[list["HuntReport"]] = relationship(back_populates="hunt", lazy="noload")
# -- Datasets ---
class Dataset(Base):
__tablename__ = "datasets"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_new_id)
name: Mapped[str] = mapped_column(String(256), nullable=False, index=True)
filename: Mapped[str] = mapped_column(String(512), nullable=False)
source_tool: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
row_count: Mapped[int] = mapped_column(Integer, default=0)
column_schema: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
normalized_columns: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
ioc_columns: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
file_size_bytes: Mapped[int] = mapped_column(Integer, default=0)
encoding: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
delimiter: Mapped[Optional[str]] = mapped_column(String(4), nullable=True)
time_range_start: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
time_range_end: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
# New Phase 1-2 columns
processing_status: Mapped[str] = mapped_column(String(20), default="ready")
artifact_type: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
file_path: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
hunt_id: Mapped[Optional[str]] = mapped_column(
String(32), ForeignKey("hunts.id"), nullable=True
)
uploaded_by: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
hunt: Mapped[Optional["Hunt"]] = relationship(back_populates="datasets", lazy="selectin")
rows: Mapped[list["DatasetRow"]] = relationship(
back_populates="dataset", lazy="noload", cascade="all, delete-orphan"
)
triage_results: Mapped[list["TriageResult"]] = relationship(
back_populates="dataset", lazy="noload", cascade="all, delete-orphan"
)
__table_args__ = (
Index("ix_datasets_hunt", "hunt_id"),
Index("ix_datasets_status", "processing_status"),
)
class DatasetRow(Base):
__tablename__ = "dataset_rows"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
dataset_id: Mapped[str] = mapped_column(
String(32), ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False
)
row_index: Mapped[int] = mapped_column(Integer, nullable=False)
data: Mapped[dict] = mapped_column(JSON, nullable=False)
normalized_data: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
dataset: Mapped["Dataset"] = relationship(back_populates="rows")
annotations: Mapped[list["Annotation"]] = relationship(
back_populates="row", lazy="noload"
)
__table_args__ = (
Index("ix_dataset_rows_dataset", "dataset_id"),
Index("ix_dataset_rows_dataset_idx", "dataset_id", "row_index"),
)
# -- Conversations ---
class Conversation(Base):
__tablename__ = "conversations"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_new_id)
title: Mapped[Optional[str]] = mapped_column(String(256), nullable=True)
hunt_id: Mapped[Optional[str]] = mapped_column(
String(32), ForeignKey("hunts.id"), nullable=True
)
dataset_id: Mapped[Optional[str]] = mapped_column(
String(32), ForeignKey("datasets.id"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
)
hunt: Mapped[Optional["Hunt"]] = relationship(back_populates="conversations", lazy="selectin")
messages: Mapped[list["Message"]] = relationship(
back_populates="conversation", lazy="selectin", cascade="all, delete-orphan",
order_by="Message.created_at",
)
class Message(Base):
__tablename__ = "messages"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
conversation_id: Mapped[str] = mapped_column(
String(32), ForeignKey("conversations.id", ondelete="CASCADE"), nullable=False
)
role: Mapped[str] = mapped_column(String(16), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
model_used: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
node_used: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
token_count: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
latency_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
response_meta: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
conversation: Mapped["Conversation"] = relationship(back_populates="messages")
__table_args__ = (
Index("ix_messages_conversation", "conversation_id"),
)
# -- Annotations ---
class Annotation(Base):
__tablename__ = "annotations"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_new_id)
row_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("dataset_rows.id", ondelete="SET NULL"), nullable=True
)
dataset_id: Mapped[Optional[str]] = mapped_column(
String(32), ForeignKey("datasets.id"), nullable=True
)
author_id: Mapped[Optional[str]] = mapped_column(
String(32), ForeignKey("users.id"), nullable=True
)
text: Mapped[str] = mapped_column(Text, nullable=False)
severity: Mapped[str] = mapped_column(String(16), default="info")
tag: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
highlight_color: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
)
row: Mapped[Optional["DatasetRow"]] = relationship(back_populates="annotations")
author: Mapped[Optional["User"]] = relationship(back_populates="annotations")
__table_args__ = (
Index("ix_annotations_dataset", "dataset_id"),
Index("ix_annotations_row", "row_id"),
)
# -- Hypotheses ---
class Hypothesis(Base):
__tablename__ = "hypotheses"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_new_id)
hunt_id: Mapped[Optional[str]] = mapped_column(
String(32), ForeignKey("hunts.id"), nullable=True
)
title: Mapped[str] = mapped_column(String(256), nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
mitre_technique: Mapped[Optional[str]] = mapped_column(String(32), nullable=True)
status: Mapped[str] = mapped_column(String(16), default="draft")
evidence_row_ids: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
evidence_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
)
hunt: Mapped[Optional["Hunt"]] = relationship(back_populates="hypotheses", lazy="selectin")
__table_args__ = (
Index("ix_hypotheses_hunt", "hunt_id"),
)
# -- Enrichment Results ---
class EnrichmentResult(Base):
__tablename__ = "enrichment_results"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_new_id)
ioc_value: Mapped[str] = mapped_column(String(512), nullable=False, index=True)
ioc_type: Mapped[str] = mapped_column(String(32), nullable=False)
source: Mapped[str] = mapped_column(String(32), nullable=False)
verdict: Mapped[Optional[str]] = mapped_column(String(16), nullable=True)
confidence: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
raw_result: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
dataset_id: Mapped[Optional[str]] = mapped_column(
String(32), ForeignKey("datasets.id"), nullable=True
)
cached_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
Index("ix_enrichment_ioc_source", "ioc_value", "source"),
)
# -- AUP Keyword Themes & Keywords ---
class KeywordTheme(Base):
__tablename__ = "keyword_themes"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_new_id)
name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
color: Mapped[str] = mapped_column(String(16), default="#9e9e9e")
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
is_builtin: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
keywords: Mapped[list["Keyword"]] = relationship(
back_populates="theme", lazy="selectin", cascade="all, delete-orphan"
)
class Keyword(Base):
__tablename__ = "keywords"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
theme_id: Mapped[str] = mapped_column(
String(32), ForeignKey("keyword_themes.id", ondelete="CASCADE"), nullable=False
)
value: Mapped[str] = mapped_column(String(256), nullable=False)
is_regex: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
theme: Mapped["KeywordTheme"] = relationship(back_populates="keywords")
__table_args__ = (
Index("ix_keywords_theme", "theme_id"),
Index("ix_keywords_value", "value"),
)
# -- AI Analysis Tables (Phase 2) ---
class TriageResult(Base):
__tablename__ = "triage_results"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_new_id)
dataset_id: Mapped[str] = mapped_column(
String(32), ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False, index=True
)
row_start: Mapped[int] = mapped_column(Integer, nullable=False)
row_end: Mapped[int] = mapped_column(Integer, nullable=False)
risk_score: Mapped[float] = mapped_column(Float, default=0.0)
verdict: Mapped[str] = mapped_column(String(20), default="pending")
findings: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
suspicious_indicators: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
mitre_techniques: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
model_used: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
node_used: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
dataset: Mapped["Dataset"] = relationship(back_populates="triage_results")
class HostProfile(Base):
__tablename__ = "host_profiles"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_new_id)
hunt_id: Mapped[str] = mapped_column(
String(32), ForeignKey("hunts.id", ondelete="CASCADE"), nullable=False, index=True
)
hostname: Mapped[str] = mapped_column(String(256), nullable=False)
fqdn: Mapped[Optional[str]] = mapped_column(String(512), nullable=True)
client_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
risk_score: Mapped[float] = mapped_column(Float, default=0.0)
risk_level: Mapped[str] = mapped_column(String(20), default="unknown")
artifact_summary: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
timeline_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
suspicious_findings: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
mitre_techniques: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
llm_analysis: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
model_used: Mapped[Optional[str]] = mapped_column(String(128), nullable=True)
node_used: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
)
hunt: Mapped["Hunt"] = relationship(back_populates="host_profiles")
class HuntReport(Base):
__tablename__ = "hunt_reports"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_new_id)
hunt_id: Mapped[str] = mapped_column(
String(32), ForeignKey("hunts.id", ondelete="CASCADE"), nullable=False, index=True
)
status: Mapped[str] = mapped_column(String(20), default="pending")
exec_summary: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
full_report: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
findings: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
recommendations: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
mitre_mapping: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
ioc_table: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
host_risk_summary: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
models_used: Mapped[Optional[list]] = mapped_column(JSON, nullable=True)
generation_time_ms: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_utcnow, onupdate=_utcnow
)
hunt: Mapped["Hunt"] = relationship(back_populates="reports")
class AnomalyResult(Base):
__tablename__ = "anomaly_results"
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=_new_id)
dataset_id: Mapped[str] = mapped_column(
String(32), ForeignKey("datasets.id", ondelete="CASCADE"), nullable=False, index=True
)
row_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("dataset_rows.id", ondelete="CASCADE"), nullable=True
)
anomaly_score: Mapped[float] = mapped_column(Float, default=0.0)
distance_from_centroid: Mapped[Optional[float]] = mapped_column(Float, nullable=True)
cluster_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
is_outlier: Mapped[bool] = mapped_column(Boolean, default=False)
explanation: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow)

View File

@@ -0,0 +1 @@
"""Repositories package — typed CRUD operations for each model."""

View File

@@ -0,0 +1,127 @@
"""Dataset repository — CRUD operations for datasets and their rows."""
import logging
from typing import Sequence
from sqlalchemy import select, func, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models import Dataset, DatasetRow
logger = logging.getLogger(__name__)
class DatasetRepository:
"""Typed CRUD for Dataset and DatasetRow models."""
def __init__(self, session: AsyncSession):
self.session = session
# ── Dataset CRUD ──────────────────────────────────────────────────
async def create_dataset(self, **kwargs) -> Dataset:
ds = Dataset(**kwargs)
self.session.add(ds)
await self.session.flush()
return ds
async def get_dataset(self, dataset_id: str) -> Dataset | None:
result = await self.session.execute(
select(Dataset).where(Dataset.id == dataset_id)
)
return result.scalar_one_or_none()
async def list_datasets(
self,
hunt_id: str | None = None,
limit: int = 100,
offset: int = 0,
) -> Sequence[Dataset]:
stmt = select(Dataset).order_by(Dataset.created_at.desc())
if hunt_id:
stmt = stmt.where(Dataset.hunt_id == hunt_id)
stmt = stmt.limit(limit).offset(offset)
result = await self.session.execute(stmt)
return result.scalars().all()
async def count_datasets(self, hunt_id: str | None = None) -> int:
stmt = select(func.count(Dataset.id))
if hunt_id:
stmt = stmt.where(Dataset.hunt_id == hunt_id)
result = await self.session.execute(stmt)
return result.scalar_one()
async def delete_dataset(self, dataset_id: str) -> bool:
ds = await self.get_dataset(dataset_id)
if not ds:
return False
await self.session.delete(ds)
await self.session.flush()
return True
# ── Row CRUD ──────────────────────────────────────────────────────
async def bulk_insert_rows(
self,
dataset_id: str,
rows: list[dict],
normalized_rows: list[dict] | None = None,
batch_size: int = 500,
) -> int:
"""Insert rows in batches. Returns count inserted."""
count = 0
for i in range(0, len(rows), batch_size):
batch = rows[i : i + batch_size]
norm_batch = normalized_rows[i : i + batch_size] if normalized_rows else [None] * len(batch)
objects = [
DatasetRow(
dataset_id=dataset_id,
row_index=i + j,
data=row,
normalized_data=norm,
)
for j, (row, norm) in enumerate(zip(batch, norm_batch))
]
self.session.add_all(objects)
await self.session.flush()
count += len(objects)
return count
async def get_rows(
self,
dataset_id: str,
limit: int = 1000,
offset: int = 0,
) -> Sequence[DatasetRow]:
stmt = (
select(DatasetRow)
.where(DatasetRow.dataset_id == dataset_id)
.order_by(DatasetRow.row_index)
.limit(limit)
.offset(offset)
)
result = await self.session.execute(stmt)
return result.scalars().all()
async def count_rows(self, dataset_id: str) -> int:
stmt = select(func.count(DatasetRow.id)).where(
DatasetRow.dataset_id == dataset_id
)
result = await self.session.execute(stmt)
return result.scalar_one()
async def get_row_by_index(
self, dataset_id: str, row_index: int
) -> DatasetRow | None:
stmt = select(DatasetRow).where(
DatasetRow.dataset_id == dataset_id,
DatasetRow.row_index == row_index,
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def delete_rows(self, dataset_id: str) -> int:
result = await self.session.execute(
delete(DatasetRow).where(DatasetRow.dataset_id == dataset_id)
)
return result.rowcount # type: ignore[return-value]

View File

@@ -1,65 +1,123 @@
"""ThreatHunt backend application.
Wires together: database, CORS, agent routes, dataset routes, hunt routes,
annotation/hypothesis routes, analysis routes, network routes, job queue,
load balancer. DB tables are auto-created on startup.
"""
import logging
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import ( from app.config import settings
auth, users, tenants, hosts, ingestion, vt, audit, from app.db import init_db, dispose_db
notifications, velociraptor, playbooks, threat_intel, reports, llm from app.api.routes.agent_v2 import router as agent_router
) from app.api.routes.datasets import router as datasets_router
from app.core.config import settings from app.api.routes.hunts import router as hunts_router
from app.api.routes.annotations import ann_router, hyp_router
from app.api.routes.enrichment import router as enrichment_router
from app.api.routes.correlation import router as correlation_router
from app.api.routes.reports import router as reports_router
from app.api.routes.auth import router as auth_router
from app.api.routes.keywords import router as keywords_router
from app.api.routes.analysis import router as analysis_router
from app.api.routes.network import router as network_router
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup / shutdown lifecycle."""
logger.info("Starting ThreatHunt API ...")
await init_db()
logger.info("Database initialised")
# Ensure uploads directory exists
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
logger.info("Upload dir: %s", os.path.abspath(settings.UPLOAD_DIR))
# Seed default AUP keyword themes
from app.db import async_session_factory
from app.services.keyword_defaults import seed_defaults
async with async_session_factory() as seed_db:
await seed_defaults(seed_db)
logger.info("AUP keyword defaults checked")
# Start job queue (Phase 10)
from app.services.job_queue import job_queue, register_all_handlers
register_all_handlers()
await job_queue.start()
logger.info("Job queue started (%d workers)", job_queue._max_workers)
# Start load balancer health loop (Phase 10)
from app.services.load_balancer import lb
await lb.start_health_loop(interval=30.0)
logger.info("Load balancer health loop started")
yield
logger.info("Shutting down ...")
# Stop job queue
from app.services.job_queue import job_queue as jq
await jq.stop()
logger.info("Job queue stopped")
# Stop load balancer
from app.services.load_balancer import lb as _lb
await _lb.stop_health_loop()
logger.info("Load balancer stopped")
from app.agents.providers_v2 import cleanup_client
from app.services.enrichment import enrichment_engine
await cleanup_client()
await enrichment_engine.cleanup()
await dispose_db()
app = FastAPI( app = FastAPI(
title=settings.app_name, title="ThreatHunt API",
description="Multi-tenant threat hunting companion for Velociraptor with ML-powered threat detection and distributed LLM routing", description="Analyst-assist threat hunting platform powered by Wile & Roadrunner LLM cluster",
version="1.1.0" version=settings.APP_VERSION,
lifespan=lifespan,
) )
# Configure CORS
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["http://localhost:3000", "http://localhost:5173"], # Frontend URLs allow_origins=settings.cors_origins,
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# Include routers # Include routes
app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"]) app.include_router(auth_router)
app.include_router(users.router, prefix="/api/users", tags=["Users"]) app.include_router(agent_router)
app.include_router(tenants.router, prefix="/api/tenants", tags=["Tenants"]) app.include_router(datasets_router)
app.include_router(hosts.router, prefix="/api/hosts", tags=["Hosts"]) app.include_router(hunts_router)
app.include_router(ingestion.router, prefix="/api/ingestion", tags=["Ingestion"]) app.include_router(ann_router)
app.include_router(vt.router, prefix="/api/vt", tags=["VirusTotal"]) app.include_router(hyp_router)
app.include_router(audit.router, prefix="/api/audit", tags=["Audit Logs"]) app.include_router(enrichment_router)
app.include_router(notifications.router, prefix="/api/notifications", tags=["Notifications"]) app.include_router(correlation_router)
app.include_router(velociraptor.router, prefix="/api/velociraptor", tags=["Velociraptor"]) app.include_router(reports_router)
app.include_router(playbooks.router, prefix="/api/playbooks", tags=["Playbooks"]) app.include_router(keywords_router)
app.include_router(threat_intel.router, prefix="/api/threat-intel", tags=["Threat Intelligence"]) app.include_router(analysis_router)
app.include_router(reports.router, prefix="/api/reports", tags=["Reports"]) app.include_router(network_router)
app.include_router(llm.router, prefix="/api/llm", tags=["Distributed LLM"])
@app.get("/") @app.get("/", tags=["health"])
async def root(): async def root():
"""Root endpoint"""
return { return {
"message": f"Welcome to {settings.app_name}", "service": "ThreatHunt API",
"version": "1.1.0", "version": settings.APP_VERSION,
"status": "running",
"docs": "/docs", "docs": "/docs",
"features": [ "cluster": {
"JWT Authentication with 2FA", "wile": settings.wile_url,
"Multi-tenant isolation", "roadrunner": settings.roadrunner_url,
"Audit logging", "openwebui": settings.OPENWEBUI_URL,
"Real-time notifications", },
"Velociraptor integration", }
"ML-powered threat detection",
"Automated playbooks",
"Advanced reporting",
"Distributed LLM routing (Phase 5)"
]
}
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy"}

View File

@@ -1,20 +0,0 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, JSON
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from app.core.database import Base
class Artifact(Base):
__tablename__ = "artifacts"
id = Column(Integer, primary_key=True, index=True)
artifact_type = Column(String, nullable=False) # hash, ip, domain, email, etc.
value = Column(String, nullable=False)
description = Column(Text, nullable=True)
case_id = Column(Integer, ForeignKey("cases.id"), nullable=True)
artifact_metadata = Column(JSON, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
# Relationships
case = relationship("Case", back_populates="artifacts")

View File

@@ -1,24 +0,0 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text, JSON
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from app.core.database import Base
class AuditLog(Base):
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False)
action = Column(String, nullable=False) # CREATE, READ, UPDATE, DELETE
resource_type = Column(String, nullable=False) # user, host, case, etc.
resource_id = Column(Integer, nullable=True)
details = Column(JSON, nullable=True)
ip_address = Column(String, nullable=True)
user_agent = Column(String, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), index=True)
# Relationships
user = relationship("User")
tenant = relationship("Tenant")

View File

@@ -1,22 +0,0 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from app.core.database import Base
class Case(Base):
__tablename__ = "cases"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, nullable=False)
description = Column(Text, nullable=True)
status = Column(String, default="open", nullable=False) # open, closed, investigating
severity = Column(String, nullable=True) # low, medium, high, critical
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
# Relationships
tenant = relationship("Tenant", back_populates="cases")
artifacts = relationship("Artifact", back_populates="case")

View File

@@ -1,21 +0,0 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, JSON
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from app.core.database import Base
class Host(Base):
__tablename__ = "hosts"
id = Column(Integer, primary_key=True, index=True)
hostname = Column(String, index=True, nullable=False)
ip_address = Column(String, nullable=True)
os = Column(String, nullable=True)
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False)
host_metadata = Column(JSON, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
last_seen = Column(DateTime, default=lambda: datetime.now(timezone.utc))
# Relationships
tenant = relationship("Tenant", back_populates="hosts")

View File

@@ -1,23 +0,0 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean, Text
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from app.core.database import Base
class Notification(Base):
__tablename__ = "notifications"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
tenant_id = Column(Integer, ForeignKey("tenants.id"), nullable=False)
title = Column(String, nullable=False)
message = Column(Text, nullable=False)
notification_type = Column(String, nullable=False) # info, warning, error, success
is_read = Column(Boolean, default=False, nullable=False)
link = Column(String, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), index=True)
# Relationships
user = relationship("User")
tenant = relationship("Tenant")

View File

@@ -1,19 +0,0 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from app.core.database import Base
class PasswordResetToken(Base):
__tablename__ = "password_reset_tokens"
id = Column(Integer, primary_key=True, index=True)
token = Column(String, unique=True, index=True, nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
expires_at = Column(DateTime, nullable=False)
is_used = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
# Relationships
user = relationship("User")

Some files were not shown because too many files have changed in this diff Show More