Compare commits

...

2 Commits

Author SHA1 Message Date
2df14dcc73 docs: initialize plan.md and update CLAUDE.md after phase 1
plan.md:
- Phase 1 détaillée et cochée (backend, frontend, infra, décisions techniques)
- Phases 2 (Nmap/Celery), 3 (vulns/CVE), 4 (PDF/dashboard) planifiées avec tâches
- Prochaine étape : feature/phase-2-nmap

CLAUDE.md:
- Structure de dossiers mise à jour (route groups, lib, core)
- Commandes corrigées (uvicorn backend.main:app, alembic depuis racine)
- Conventions Phase 1 : imports backend.xxx, Pydantic v2, PATCH pattern,
  selectinload, TimestampMixin, guard auth frontend, camelCase types TS
- Pièges à éviter documentés

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 17:24:47 +01:00
0fe1a1b751 feat: Phase 1 — socle backend FastAPI + frontend Next.js
Backend (FastAPI + SQLAlchemy):
- Modèles : User, Client, Audit, Cible, Vulnérabilité, Action
- Auth JWT (register/login/me) avec bcrypt
- Routes CRUD complets : clients, audits, cibles, vulnérabilités, actions
- Schémas Pydantic v2, migrations Alembic configurées
- Rate limiting (slowapi), CORS, structure scanners/reports pour phase 2

Frontend (Next.js 14 App Router):
- shadcn/ui : Button, Input, Card, Badge, Label
- Page login avec gestion token JWT
- Dashboard avec stats temps réel
- Pages Clients (grille) et Audits (liste) avec recherche
- Layout avec sidebar navigation + protection auth
- Dockerfiles multi-stage (backend + frontend standalone)

Infrastructure:
- docker-compose.yml : postgres, redis, backend, frontend
- docker-compose.prod.yml avec labels Traefik
- .env.example complet
- .gitignore mis à jour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 17:16:12 +01:00
62 changed files with 2596 additions and 20 deletions

View File

@@ -0,0 +1,28 @@
# =============================================================================
# AuditShield — Variables d'environnement
# Copier ce fichier en .env et remplir les valeurs
# NE JAMAIS commiter le fichier .env
# =============================================================================
# --- Application ---
SECRET_KEY=changeme-generate-a-strong-random-key-here
DEBUG=false
# --- Base de données PostgreSQL ---
POSTGRES_DB=auditshield
POSTGRES_USER=auditshield
POSTGRES_PASSWORD=changeme-strong-password
# Construit automatiquement par docker-compose, à définir manuellement en dev local :
DATABASE_URL=postgresql://auditshield:changeme-strong-password@localhost:5432/auditshield
# --- Redis / Celery ---
REDIS_URL=redis://redis:6379/0
# --- Frontend ---
NEXT_PUBLIC_API_URL=http://localhost:8000
# --- Déploiement ---
DOMAIN=auditshield.rigolet.tech
REGISTRY=registry.rigolet.tech
TAG=latest

29
.gitignore vendored
View File

@@ -1,7 +1,30 @@
# Environment
.env
node_modules/
.env.local
.env.*.local
# Python
__pycache__/
*.pyc
.DS_Store
*.py[cod]
*.pyo
.venv/
venv/
.pytest_cache/
.mypy_cache/
htmlcov/
.coverage
test.db
# Node.js
node_modules/
.next/
out/
# Build
dist/
build/
# IDE
.vscode/
.idea/
.DS_Store

View File

@@ -15,17 +15,33 @@ Outil d'audit infrastructure et sécurité pour clients MSP. Permet de lancer de
## Structure
```
frontend/
app/ # Next.js App Router
components/ # Composants réutilisables
lib/ # Utilitaires, API client
app/
(auth)/login/ # Page login (pas de layout dashboard)
(dashboard)/ # Pages protégées avec sidebar
layout.tsx # Guard auth + layout sidebar+main
dashboard/ # Stats globales
clients/ # CRUD clients
audits/ # CRUD audits
components/
layout/ # Sidebar, Header
ui/ # shadcn/ui : Button, Card, Input, Badge, Label
lib/
api.ts # Client fetch typé (authApi, clientsApi, auditsApi)
auth.ts # Helpers JWT localStorage
utils.ts # cn() (tailwind-merge + clsx)
backend/
api/ # Routes FastAPI
scanners/ # Orchestration Nmap, OpenVAS, Metasploit, AD
models/ # Modèles SQLAlchemy
reports/ # Génération PDF
api/ # Routes FastAPI (auth, clients, audits)
core/ # config.py, database.py, security.py
models/ # SQLAlchemy ORM (base.py + TimestampMixin)
schemas/ # Pydantic v2 (séparés des modèles)
scanners/ # Placeholder Phase 2 (Nmap, OpenVAS...)
reports/ # Placeholder Phase 4 (WeasyPrint PDF)
tests/ # pytest async avec SQLite in-memory
alembic/ # Migrations (env.py configuré)
main.py # App FastAPI : CORS, rate limiting, routers
docker/
docker-compose.yml
docker-compose.prod.yml
docker-compose.yml # Dev : postgres, redis, backend, frontend
docker-compose.prod.yml # Prod : images registry + labels Traefik
```
## Commandes
@@ -33,17 +49,23 @@ docker/
# Frontend
cd frontend && npm install && npm run dev
# Backend
cd backend && pip install -r requirements.txt && uvicorn main:app --reload
# Backend (lancer depuis la racine du projet, pas depuis backend/)
pip install -r backend/requirements.txt
uvicorn backend.main:app --reload
# Docker complet
docker compose -f docker/docker-compose.yml up -d
# Migrations BDD
cd backend && alembic upgrade head
# Migrations BDD (depuis la racine, pas depuis backend/)
alembic -c backend/alembic.ini upgrade head
# Créer une migration :
alembic -c backend/alembic.ini revision --autogenerate -m "description"
# Tests
pytest backend/tests/
# Typecheck frontend
cd frontend && npm run typecheck
```
## Modèle de données
@@ -77,8 +99,49 @@ Gitea Actions → Docker → NAS ou VPS selon le client
Voir @docker/docker-compose.yml et @.gitea/workflows/deploy.yml
## Ce que Claude doit savoir sur CE projet
### Domaine métier
- Les scans sont des opérations longues → utiliser des jobs asynchrones (Celery ou BackgroundTasks FastAPI)
- Le rapport PDF doit être compréhensible par un dirigeant non-technique
- Toujours traduire les CVE en langage clair dans les rapports
- La BDD utilise snake_case, le frontend TypeScript utilise camelCase
- Les criticités : critique (CVSS 9-10), important (7-8.9), modéré (4-6.9), faible (0-3.9)
- Un scan ne peut se lancer que sur des cibles dont `validee = True` (sécurité obligatoire)
### Conventions de code établies en Phase 1
**Backend :**
- Imports toujours en `backend.xxx` (ex: `from backend.models.user import User`), jamais en relatif — le module s'appelle `backend`
- Uvicorn se lance avec `uvicorn backend.main:app`, pas `uvicorn main:app`
- Alembic se lance depuis la racine avec `-c backend/alembic.ini`
- Les enums Python sont aussi `str` (`class Criticite(str, enum.Enum)`) pour sérialisation JSON automatique
- Pydantic v2 : utiliser `model_dump()` (pas `.dict()`), `model_config = {"from_attributes": True}` (pas `orm_mode`)
- Pattern PATCH : `payload.model_dump(exclude_unset=True)` pour ne pas écraser les champs non fournis
- `get_current_user` est une dépendance FastAPI — toujours l'injecter sur les routes protégées
- `selectinload()` pour les relations dans les endpoints "detail" (évite le N+1)
- `TimestampMixin` à hériter sur tous les nouveaux modèles
- `pool_pre_ping=True` sur l'engine SQLAlchemy pour les connexions longues durée
**Frontend :**
- Les types TypeScript sont en camelCase même si l'API renvoie du snake_case (mapping à faire côté fetch)
- `"use client"` obligatoire sur toutes les pages/composants utilisant des hooks React ou des effets
- Guard auth dans `(dashboard)/layout.tsx` via `useEffect` + `getToken()` — pas de middleware Next.js
- Le client API (`lib/api.ts`) passe toujours le token en paramètre explicite — pas de contexte global
- Les nouvelles pages du dashboard vont dans `app/(dashboard)/[section]/page.tsx`
- Les nouveaux composants UI shadcn vont dans `components/ui/`, les composants métier dans `components/`
- Les rewrites `next.config.ts` proxifient `/api/*` vers le backend — en dev, définir `NEXT_PUBLIC_API_URL=http://localhost:8000`
- JWT stocké dans localStorage (simple pour MVP, à migrer vers HttpOnly cookie plus tard)
**Infrastructure :**
- Le service `backend` dans docker-compose dépend de `postgres` avec `condition: service_healthy`
- Le build frontend Next.js utilise `output: "standalone"` → le Dockerfile copie `.next/standalone`
- Les variables d'env préfixées `NEXT_PUBLIC_` sont embarquées au build (pas au runtime)
- `docker-compose.prod.yml` attend des images pré-buildées depuis `${REGISTRY}` — pas de build local
### Pièges à éviter
- Ne pas lancer `uvicorn main:app` depuis `backend/` — toujours depuis la racine avec le module complet
- Ne pas oublier `"use client"` sur les pages avec `useEffect`, `useState`, `useRouter`
- Ne pas utiliser `.dict()` Pydantic v1 — c'est `.model_dump()` en v2
- Ne pas faire de scans sans vérifier `cible.validee == True` côté backend
- Ne pas commiter `.env` — seulement `.env.example`
- Alembic `env.py` importe les modèles depuis `backend.models` — ajouter tout nouveau modèle dans `backend/models/__init__.py`

16
backend/Dockerfile Normal file
View File

@@ -0,0 +1,16 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
libpango-1.0-0 libpangoft2-1.0-0 libffi-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN alembic upgrade head || true
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]

41
backend/alembic.ini Normal file
View File

@@ -0,0 +1,41 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql://user:password@localhost/auditshield
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

48
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,48 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from backend.models import Base
from backend.core.config import settings
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

0
backend/api/__init__.py Normal file
View File

191
backend/api/audits.py Normal file
View File

@@ -0,0 +1,191 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session, selectinload
from backend.core.database import get_db
from backend.core.security import get_current_user
from backend.models.audit import Audit
from backend.models.target import Cible
from backend.models.vulnerability import Vulnerabilite
from backend.models.action import Action
from backend.models.user import User
from backend.schemas.audit import (
AuditCreate,
AuditUpdate,
AuditRead,
AuditDetail,
CibleCreate,
CibleRead,
VulnerabiliteCreate,
VulnerabiliteRead,
ActionCreate,
ActionUpdate,
ActionRead,
)
router = APIRouter(prefix="/audits", tags=["audits"])
@router.get("/", response_model=list[AuditRead])
def list_audits(
client_id: int | None = None,
skip: int = 0,
limit: int = 50,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[Audit]:
q = db.query(Audit)
if client_id:
q = q.filter(Audit.client_id == client_id)
return q.offset(skip).limit(limit).all()
@router.post("/", response_model=AuditRead, status_code=status.HTTP_201_CREATED)
def create_audit(
payload: AuditCreate,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Audit:
audit = Audit(**payload.model_dump())
db.add(audit)
db.commit()
db.refresh(audit)
return audit
@router.get("/{audit_id}", response_model=AuditDetail)
def get_audit(
audit_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Audit:
audit = (
db.query(Audit)
.options(selectinload(Audit.cibles), selectinload(Audit.vulnerabilites))
.filter(Audit.id == audit_id)
.first()
)
if not audit:
raise HTTPException(status_code=404, detail="Audit introuvable")
return audit
@router.patch("/{audit_id}", response_model=AuditRead)
def update_audit(
audit_id: int,
payload: AuditUpdate,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Audit:
audit = db.get(Audit, audit_id)
if not audit:
raise HTTPException(status_code=404, detail="Audit introuvable")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(audit, field, value)
db.commit()
db.refresh(audit)
return audit
@router.delete("/{audit_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_audit(
audit_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> None:
audit = db.get(Audit, audit_id)
if not audit:
raise HTTPException(status_code=404, detail="Audit introuvable")
db.delete(audit)
db.commit()
# --- Cibles ---
@router.post("/{audit_id}/cibles", response_model=CibleRead, status_code=status.HTTP_201_CREATED)
def add_cible(
audit_id: int,
payload: CibleCreate,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Cible:
if not db.get(Audit, audit_id):
raise HTTPException(status_code=404, detail="Audit introuvable")
cible = Cible(audit_id=audit_id, **payload.model_dump())
db.add(cible)
db.commit()
db.refresh(cible)
return cible
@router.patch("/{audit_id}/cibles/{cible_id}/valider", response_model=CibleRead)
def valider_cible(
audit_id: int,
cible_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Cible:
cible = db.query(Cible).filter(Cible.id == cible_id, Cible.audit_id == audit_id).first()
if not cible:
raise HTTPException(status_code=404, detail="Cible introuvable")
cible.validee = True
db.commit()
db.refresh(cible)
return cible
# --- Vulnérabilités ---
@router.post("/{audit_id}/vulnerabilites", response_model=VulnerabiliteRead, status_code=status.HTTP_201_CREATED)
def add_vulnerabilite(
audit_id: int,
payload: VulnerabiliteCreate,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Vulnerabilite:
if not db.get(Audit, audit_id):
raise HTTPException(status_code=404, detail="Audit introuvable")
vuln = Vulnerabilite(audit_id=audit_id, **payload.model_dump())
db.add(vuln)
db.commit()
db.refresh(vuln)
return vuln
# --- Actions ---
@router.post("/{audit_id}/actions", response_model=ActionRead, status_code=status.HTTP_201_CREATED)
def add_action(
audit_id: int,
payload: ActionCreate,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Action:
vuln = db.get(Vulnerabilite, payload.vulnerabilite_id)
if not vuln or vuln.audit_id != audit_id:
raise HTTPException(status_code=404, detail="Vulnérabilité introuvable dans cet audit")
action = Action(**payload.model_dump())
db.add(action)
db.commit()
db.refresh(action)
return action
@router.patch("/{audit_id}/actions/{action_id}", response_model=ActionRead)
def update_action(
audit_id: int,
action_id: int,
payload: ActionUpdate,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Action:
action = db.get(Action, action_id)
if not action:
raise HTTPException(status_code=404, detail="Action introuvable")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(action, field, value)
db.commit()
db.refresh(action)
return action

46
backend/api/auth.py Normal file
View File

@@ -0,0 +1,46 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from backend.core.database import get_db
from backend.core.security import hash_password, verify_password, create_access_token, get_current_user
from backend.models.user import User
from backend.schemas.user import UserCreate, UserRead, Token
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED)
def register(payload: UserCreate, db: Session = Depends(get_db)) -> User:
if db.query(User).filter(User.email == payload.email).first():
raise HTTPException(status_code=400, detail="Email déjà utilisé")
user = User(
email=payload.email,
full_name=payload.full_name,
hashed_password=hash_password(payload.password),
)
db.add(user)
db.commit()
db.refresh(user)
return user
@router.post("/login", response_model=Token)
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)) -> Token:
user = db.query(User).filter(User.email == form_data.username).first()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email ou mot de passe incorrect",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(status_code=403, detail="Compte désactivé")
token = create_access_token(subject=user.id)
return Token(access_token=token)
@router.get("/me", response_model=UserRead)
def me(current_user: User = Depends(get_current_user)) -> User:
return current_user

76
backend/api/clients.py Normal file
View File

@@ -0,0 +1,76 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from backend.core.database import get_db
from backend.core.security import get_current_user
from backend.models.client import Client
from backend.models.user import User
from backend.schemas.client import ClientCreate, ClientUpdate, ClientRead
router = APIRouter(prefix="/clients", tags=["clients"])
@router.get("/", response_model=list[ClientRead])
def list_clients(
skip: int = 0,
limit: int = 50,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[Client]:
return db.query(Client).offset(skip).limit(limit).all()
@router.post("/", response_model=ClientRead, status_code=status.HTTP_201_CREATED)
def create_client(
payload: ClientCreate,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Client:
client = Client(**payload.model_dump())
db.add(client)
db.commit()
db.refresh(client)
return client
@router.get("/{client_id}", response_model=ClientRead)
def get_client(
client_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Client:
client = db.get(Client, client_id)
if not client:
raise HTTPException(status_code=404, detail="Client introuvable")
return client
@router.patch("/{client_id}", response_model=ClientRead)
def update_client(
client_id: int,
payload: ClientUpdate,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Client:
client = db.get(Client, client_id)
if not client:
raise HTTPException(status_code=404, detail="Client introuvable")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(client, field, value)
db.commit()
db.refresh(client)
return client
@router.delete("/{client_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_client(
client_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> None:
client = db.get(Client, client_id)
if not client:
raise HTTPException(status_code=404, detail="Client introuvable")
db.delete(client)
db.commit()

0
backend/core/__init__.py Normal file
View File

28
backend/core/config.py Normal file
View File

@@ -0,0 +1,28 @@
from pydantic_settings import BaseSettings
from functools import lru_cache
class Settings(BaseSettings):
# Application
app_name: str = "AuditShield"
debug: bool = False
secret_key: str
algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 # 24h
# Database
database_url: str
# Redis / Celery
redis_url: str = "redis://redis:6379/0"
class Config:
env_file = ".env"
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()

15
backend/core/database.py Normal file
View File

@@ -0,0 +1,15 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from typing import Generator
from backend.core.config import settings
engine = create_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
try:
yield db
finally:
db.close()

56
backend/core/security.py Normal file
View File

@@ -0,0 +1,56 @@
from datetime import datetime, timedelta, timezone
from typing import Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import HTTPException, status, Depends
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from backend.core.config import settings
from backend.core.database import get_db
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(subject: Any, expires_delta: timedelta | None = None) -> str:
expire = datetime.now(timezone.utc) + (
expires_delta or timedelta(minutes=settings.access_token_expire_minutes)
)
payload = {"sub": str(subject), "exp": expire}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def decode_token(token: str) -> dict:
try:
return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token invalide ou expiré",
headers={"WWW-Authenticate": "Bearer"},
)
def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db),
):
from backend.models.user import User
payload = decode_token(token)
user_id: str = payload.get("sub")
if not user_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token invalide")
user = db.get(User, int(user_id))
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Utilisateur introuvable ou inactif")
return user

39
backend/main.py Normal file
View File

@@ -0,0 +1,39 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from backend.core.config import settings
from backend.api.auth import router as auth_router
from backend.api.clients import router as clients_router
from backend.api.audits import router as audits_router
limiter = Limiter(key_func=get_remote_address)
app = FastAPI(
title=settings.app_name,
description="API d'audit infrastructure et sécurité pour MSP",
version="1.0.0",
docs_url="/api/docs" if settings.debug else None,
redoc_url="/api/redoc" if settings.debug else None,
)
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router, prefix="/api")
app.include_router(clients_router, prefix="/api")
app.include_router(audits_router, prefix="/api")
@app.get("/api/health")
def health() -> dict:
return {"status": "ok", "service": settings.app_name}

View File

@@ -0,0 +1,21 @@
from backend.models.base import Base
from backend.models.user import User
from backend.models.client import Client
from backend.models.audit import Audit, AuditStatut
from backend.models.target import Cible, CibleType
from backend.models.vulnerability import Vulnerabilite, Criticite
from backend.models.action import Action, ActionStatut
__all__ = [
"Base",
"User",
"Client",
"Audit",
"AuditStatut",
"Cible",
"CibleType",
"Vulnerabilite",
"Criticite",
"Action",
"ActionStatut",
]

22
backend/models/action.py Normal file
View File

@@ -0,0 +1,22 @@
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import String, Text, ForeignKey, Enum as SAEnum
import enum
from backend.models.base import Base, TimestampMixin
class ActionStatut(str, enum.Enum):
ouvert = "ouvert"
en_cours = "en_cours"
resolu = "resolu"
class Action(Base, TimestampMixin):
__tablename__ = "actions"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
vulnerabilite_id: Mapped[int] = mapped_column(ForeignKey("vulnerabilites.id"), nullable=False, index=True)
statut: Mapped[ActionStatut] = mapped_column(SAEnum(ActionStatut), default=ActionStatut.ouvert, nullable=False)
assigne_a: Mapped[str | None] = mapped_column(String(255), nullable=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
vulnerabilite: Mapped["Vulnerabilite"] = relationship("Vulnerabilite", back_populates="actions")

30
backend/models/audit.py Normal file
View File

@@ -0,0 +1,30 @@
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import String, Integer, Float, ForeignKey, DateTime, Enum as SAEnum
from datetime import datetime
import enum
from backend.models.base import Base, TimestampMixin
class AuditStatut(str, enum.Enum):
planifie = "planifie"
en_cours = "en_cours"
termine = "termine"
annule = "annule"
class Audit(Base, TimestampMixin):
__tablename__ = "audits"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
client_id: Mapped[int] = mapped_column(ForeignKey("clients.id"), nullable=False, index=True)
nom: Mapped[str] = mapped_column(String(255), nullable=False)
statut: Mapped[AuditStatut] = mapped_column(SAEnum(AuditStatut), default=AuditStatut.planifie, nullable=False)
date_debut: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
date_fin: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
score_global: Mapped[float | None] = mapped_column(Float, nullable=True)
client: Mapped["Client"] = relationship("Client", back_populates="audits")
cibles: Mapped[list["Cible"]] = relationship("Cible", back_populates="audit", cascade="all, delete-orphan")
vulnerabilites: Mapped[list["Vulnerabilite"]] = relationship(
"Vulnerabilite", back_populates="audit", cascade="all, delete-orphan"
)

16
backend/models/base.py Normal file
View File

@@ -0,0 +1,16 @@
from sqlalchemy.orm import DeclarativeBase, mapped_column, Mapped
from sqlalchemy import DateTime, func
from datetime import datetime
class Base(DeclarativeBase):
pass
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False
)

16
backend/models/client.py Normal file
View File

@@ -0,0 +1,16 @@
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import String, Text
from backend.models.base import Base, TimestampMixin
class Client(Base, TimestampMixin):
__tablename__ = "clients"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
nom: Mapped[str] = mapped_column(String(255), nullable=False)
contact: Mapped[str | None] = mapped_column(String(255), nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
telephone: Mapped[str | None] = mapped_column(String(50), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
audits: Mapped[list["Audit"]] = relationship("Audit", back_populates="client", cascade="all, delete-orphan")

22
backend/models/target.py Normal file
View File

@@ -0,0 +1,22 @@
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import String, ForeignKey, Enum as SAEnum
import enum
from backend.models.base import Base, TimestampMixin
class CibleType(str, enum.Enum):
ip = "ip"
domaine = "domaine"
subnet = "subnet"
class Cible(Base, TimestampMixin):
__tablename__ = "cibles"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
audit_id: Mapped[int] = mapped_column(ForeignKey("audits.id"), nullable=False, index=True)
type: Mapped[CibleType] = mapped_column(SAEnum(CibleType), nullable=False)
valeur: Mapped[str] = mapped_column(String(255), nullable=False)
validee: Mapped[bool] = mapped_column(default=False, nullable=False)
audit: Mapped["Audit"] = relationship("Audit", back_populates="cibles")

14
backend/models/user.py Normal file
View File

@@ -0,0 +1,14 @@
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import String, Boolean
from backend.models.base import Base, TimestampMixin
class User(Base, TimestampMixin):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
full_name: Mapped[str] = mapped_column(String(255), nullable=False)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)

View File

@@ -0,0 +1,28 @@
from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy import String, Text, Float, ForeignKey, Enum as SAEnum
import enum
from backend.models.base import Base, TimestampMixin
class Criticite(str, enum.Enum):
critique = "critique" # CVSS 9-10
important = "important" # CVSS 7-8.9
modere = "modere" # CVSS 4-6.9
faible = "faible" # CVSS 0-3.9
class Vulnerabilite(Base, TimestampMixin):
__tablename__ = "vulnerabilites"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
audit_id: Mapped[int] = mapped_column(ForeignKey("audits.id"), nullable=False, index=True)
criticite: Mapped[Criticite] = mapped_column(SAEnum(Criticite), nullable=False)
titre: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
recommandation: Mapped[str] = mapped_column(Text, nullable=False)
cve: Mapped[str | None] = mapped_column(String(50), nullable=True)
cvss_score: Mapped[float | None] = mapped_column(Float, nullable=True)
cible: Mapped[str | None] = mapped_column(String(255), nullable=True)
audit: Mapped["Audit"] = relationship("Audit", back_populates="vulnerabilites")
actions: Mapped[list["Action"]] = relationship("Action", back_populates="vulnerabilite", cascade="all, delete-orphan")

View File

@@ -0,0 +1 @@
# Génération PDF WeasyPrint — Phase 2

17
backend/requirements.txt Normal file
View File

@@ -0,0 +1,17 @@
fastapi==0.111.0
uvicorn[standard]==0.29.0
sqlalchemy==2.0.30
alembic==1.13.1
psycopg2-binary==2.9.9
pydantic[email]==2.7.1
pydantic-settings==2.2.1
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.9
python-dotenv==1.0.1
weasyprint==62.3
slowapi==0.1.9
pytest==8.2.0
httpx==0.27.0
celery==5.4.0
redis==5.0.4

View File

@@ -0,0 +1 @@
# Scanners : Nmap, OpenVAS, Metasploit, AD — Phase 2

View File

99
backend/schemas/audit.py Normal file
View File

@@ -0,0 +1,99 @@
from pydantic import BaseModel
from datetime import datetime
from backend.models.audit import AuditStatut
from backend.models.target import CibleType
from backend.models.vulnerability import Criticite
from backend.models.action import ActionStatut
class CibleCreate(BaseModel):
type: CibleType
valeur: str
class CibleRead(BaseModel):
id: int
audit_id: int
type: CibleType
valeur: str
validee: bool
model_config = {"from_attributes": True}
class VulnerabiliteCreate(BaseModel):
criticite: Criticite
titre: str
description: str
recommandation: str
cve: str | None = None
cvss_score: float | None = None
cible: str | None = None
class VulnerabiliteRead(BaseModel):
id: int
audit_id: int
criticite: Criticite
titre: str
description: str
recommandation: str
cve: str | None
cvss_score: float | None
cible: str | None
model_config = {"from_attributes": True}
class ActionCreate(BaseModel):
vulnerabilite_id: int
assigne_a: str | None = None
note: str | None = None
class ActionUpdate(BaseModel):
statut: ActionStatut | None = None
assigne_a: str | None = None
note: str | None = None
class ActionRead(BaseModel):
id: int
vulnerabilite_id: int
statut: ActionStatut
assigne_a: str | None
note: str | None
model_config = {"from_attributes": True}
class AuditCreate(BaseModel):
client_id: int
nom: str
date_debut: datetime | None = None
class AuditUpdate(BaseModel):
nom: str | None = None
statut: AuditStatut | None = None
date_debut: datetime | None = None
date_fin: datetime | None = None
score_global: float | None = None
class AuditRead(BaseModel):
id: int
client_id: int
nom: str
statut: AuditStatut
date_debut: datetime | None
date_fin: datetime | None
score_global: float | None
created_at: datetime
model_config = {"from_attributes": True}
class AuditDetail(AuditRead):
cibles: list[CibleRead] = []
vulnerabilites: list[VulnerabiliteRead] = []

30
backend/schemas/client.py Normal file
View File

@@ -0,0 +1,30 @@
from pydantic import BaseModel, EmailStr
from datetime import datetime
class ClientCreate(BaseModel):
nom: str
contact: str | None = None
email: str | None = None
telephone: str | None = None
notes: str | None = None
class ClientUpdate(BaseModel):
nom: str | None = None
contact: str | None = None
email: str | None = None
telephone: str | None = None
notes: str | None = None
class ClientRead(BaseModel):
id: int
nom: str
contact: str | None
email: str | None
telephone: str | None
notes: str | None
created_at: datetime
model_config = {"from_attributes": True}

26
backend/schemas/user.py Normal file
View File

@@ -0,0 +1,26 @@
from pydantic import BaseModel, EmailStr
class UserCreate(BaseModel):
email: EmailStr
full_name: str
password: str
class UserRead(BaseModel):
id: int
email: str
full_name: str
is_active: bool
is_admin: bool
model_config = {"from_attributes": True}
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
user_id: int

View File

View File

@@ -0,0 +1,48 @@
import pytest
from httpx import AsyncClient, ASGITransport
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from backend.main import app
from backend.models.base import Base
from backend.core.database import get_db
TEST_DATABASE_URL = "sqlite:///./test.db"
engine = create_engine(TEST_DATABASE_URL, connect_args={"check_same_thread": False})
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def override_get_db():
db = TestingSessionLocal()
try:
yield db
finally:
db.close()
@pytest.fixture(autouse=True)
def setup_db():
Base.metadata.create_all(bind=engine)
app.dependency_overrides[get_db] = override_get_db
yield
Base.metadata.drop_all(bind=engine)
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_register_and_login():
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
r = await client.post("/api/auth/register", json={
"email": "test@example.com",
"full_name": "Test User",
"password": "secret123",
})
assert r.status_code == 201
assert r.json()["email"] == "test@example.com"
r = await client.post("/api/auth/login", data={
"username": "test@example.com",
"password": "secret123",
})
assert r.status_code == 200
assert "access_token" in r.json()

View File

@@ -0,0 +1,73 @@
version: "3.8"
services:
postgres:
image: postgres:16-alpine
container_name: auditshield-db-prod
restart: always
environment:
POSTGRES_DB: ${POSTGRES_DB:-auditshield}
POSTGRES_USER: ${POSTGRES_USER:-auditshield}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD required}
volumes:
- postgres_data_prod:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-auditshield}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
redis:
image: redis:7-alpine
container_name: auditshield-redis-prod
restart: always
networks:
- internal
backend:
image: ${REGISTRY}/auditshield-backend:${TAG:-latest}
container_name: auditshield-backend-prod
restart: always
env_file: .env
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-auditshield}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-auditshield}
REDIS_URL: redis://redis:6379/0
depends_on:
postgres:
condition: service_healthy
networks:
- internal
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.auditshield-api.rule=Host(`${DOMAIN}`) && PathPrefix(`/api`)"
- "traefik.http.routers.auditshield-api.entrypoints=websecure"
- "traefik.http.routers.auditshield-api.tls.certresolver=letsencrypt"
frontend:
image: ${REGISTRY}/auditshield-frontend:${TAG:-latest}
container_name: auditshield-frontend-prod
restart: always
environment:
NEXT_PUBLIC_API_URL: ""
depends_on:
- backend
networks:
- internal
- proxy
labels:
- "traefik.enable=true"
- "traefik.http.routers.auditshield.rule=Host(`${DOMAIN}`)"
- "traefik.http.routers.auditshield.entrypoints=websecure"
- "traefik.http.routers.auditshield.tls.certresolver=letsencrypt"
volumes:
postgres_data_prod:
networks:
internal:
driver: bridge
proxy:
external: true

View File

@@ -1,13 +1,69 @@
version: "3.8"
services:
app:
image: your-app:latest
container_name: your-app
postgres:
image: postgres:16-alpine
container_name: auditshield-db
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-auditshield}
POSTGRES_USER: ${POSTGRES_USER:-auditshield}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD required}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-auditshield}"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
redis:
image: redis:7-alpine
container_name: auditshield-redis
restart: unless-stopped
networks:
- internal
backend:
build:
context: ../backend
dockerfile: Dockerfile
container_name: auditshield-backend
restart: unless-stopped
env_file: ../.env
environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-auditshield}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-auditshield}
REDIS_URL: redis://redis:6379/0
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_started
networks:
- internal
- proxy
frontend:
build:
context: ../frontend
dockerfile: Dockerfile
container_name: auditshield-frontend
restart: unless-stopped
environment:
NEXT_PUBLIC_API_URL: ""
depends_on:
- backend
networks:
- internal
- proxy
volumes:
postgres_data:
networks:
internal:
driver: bridge
proxy:
external: true

27
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,27 @@
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]

View File

@@ -0,0 +1,81 @@
"use client";
import { useState, FormEvent } from "react";
import { useRouter } from "next/navigation";
import { Shield } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { authApi } from "@/lib/api";
import { setToken } from "@/lib/auth";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setLoading(true);
try {
const { access_token } = await authApi.login(email, password);
setToken(access_token);
router.push("/dashboard");
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur de connexion");
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-muted/40">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="flex justify-center mb-2">
<Shield className="h-10 w-10 text-primary" />
</div>
<CardTitle className="text-2xl">AuditShield</CardTitle>
<CardDescription>Connectez-vous à votre espace sécurisé</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="vous@exemple.fr"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Mot de passe</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
autoComplete="current-password"
/>
</div>
{error && (
<p className="text-sm text-destructive text-center">{error}</p>
)}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Connexion..." : "Se connecter"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { useEffect, useState } from "react";
import { Plus, Search, ClipboardList } from "lucide-react";
import { Header } from "@/components/layout/header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { auditsApi, type Audit } from "@/lib/api";
import { getToken } from "@/lib/auth";
import Link from "next/link";
const statutLabels: Record<string, string> = {
planifie: "Planifié",
en_cours: "En cours",
termine: "Terminé",
annule: "Annulé",
};
const statutVariant: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
planifie: "secondary",
en_cours: "default",
termine: "outline",
annule: "destructive",
};
export default function AuditsPage() {
const [audits, setAudits] = useState<Audit[]>([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = getToken();
if (!token) return;
auditsApi.list(token).then(setAudits).finally(() => setLoading(false));
}, []);
const filtered = audits.filter((a) =>
a.nom.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="flex flex-col h-full">
<Header title="Audits" />
<div className="flex-1 p-6 space-y-4">
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Rechercher un audit..."
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Button asChild>
<Link href="/audits/nouveau">
<Plus className="h-4 w-4 mr-2" />
Nouvel audit
</Link>
</Button>
</div>
{loading ? (
<p className="text-sm text-muted-foreground">Chargement...</p>
) : filtered.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<ClipboardList className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p className="font-medium">Aucun audit trouvé</p>
</div>
) : (
<div className="space-y-3">
{filtered.map((audit) => (
<Link key={audit.id} href={`/audits/${audit.id}`}>
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="font-semibold">{audit.nom}</p>
<p className="text-sm text-muted-foreground">
{audit.dateDebut
? new Date(audit.dateDebut).toLocaleDateString("fr-FR")
: "Date non définie"}
</p>
</div>
<div className="flex items-center gap-3">
{audit.scoreGlobal !== null && (
<span className="text-sm font-medium text-muted-foreground">
Score : {audit.scoreGlobal.toFixed(1)}
</span>
)}
<Badge variant={statutVariant[audit.statut]}>
{statutLabels[audit.statut]}
</Badge>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,107 @@
"use client";
import { useEffect, useState } from "react";
import { Plus, Search, Building2, Mail, Phone } from "lucide-react";
import { Header } from "@/components/layout/header";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { clientsApi, type Client } from "@/lib/api";
import { getToken } from "@/lib/auth";
import Link from "next/link";
export default function ClientsPage() {
const [clients, setClients] = useState<Client[]>([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = getToken();
if (!token) return;
clientsApi.list(token).then(setClients).finally(() => setLoading(false));
}, []);
const filtered = clients.filter((c) =>
c.nom.toLowerCase().includes(search.toLowerCase()) ||
c.contact?.toLowerCase().includes(search.toLowerCase()) ||
c.email?.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="flex flex-col h-full">
<Header title="Clients" />
<div className="flex-1 p-6 space-y-4">
{/* Toolbar */}
<div className="flex items-center gap-3">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Rechercher un client..."
className="pl-9"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<Button asChild>
<Link href="/clients/nouveau">
<Plus className="h-4 w-4 mr-2" />
Nouveau client
</Link>
</Button>
</div>
{/* Client list */}
{loading ? (
<p className="text-sm text-muted-foreground">Chargement...</p>
) : filtered.length === 0 ? (
<div className="text-center py-16 text-muted-foreground">
<Building2 className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p className="font-medium">Aucun client trouvé</p>
<p className="text-sm">Créez votre premier client pour commencer.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filtered.map((client) => (
<Link key={client.id} href={`/clients/${client.id}`}>
<Card className="hover:shadow-md transition-shadow cursor-pointer h-full">
<CardContent className="p-5 space-y-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<div className="rounded-full bg-primary/10 p-2">
<Building2 className="h-4 w-4 text-primary" />
</div>
<h3 className="font-semibold">{client.nom}</h3>
</div>
</div>
<div className="space-y-1 text-sm text-muted-foreground">
{client.contact && (
<p className="flex items-center gap-1">
<span className="font-medium text-foreground">{client.contact}</span>
</p>
)}
{client.email && (
<p className="flex items-center gap-1.5">
<Mail className="h-3.5 w-3.5" />
{client.email}
</p>
)}
{client.telephone && (
<p className="flex items-center gap-1.5">
<Phone className="h-3.5 w-3.5" />
{client.telephone}
</p>
)}
</div>
<p className="text-xs text-muted-foreground border-t pt-2">
Créé le {new Date(client.createdAt).toLocaleDateString("fr-FR")}
</p>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,120 @@
"use client";
import { useEffect, useState } from "react";
import { Users, ClipboardList, ShieldAlert, CheckCircle } from "lucide-react";
import { Header } from "@/components/layout/header";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { clientsApi, auditsApi, type Client, type Audit } from "@/lib/api";
import { getToken } from "@/lib/auth";
type Stats = {
totalClients: number;
totalAudits: number;
auditsEnCours: number;
auditsTermines: number;
};
export default function DashboardPage() {
const [stats, setStats] = useState<Stats>({
totalClients: 0,
totalAudits: 0,
auditsEnCours: 0,
auditsTermines: 0,
});
const [recentAudits, setRecentAudits] = useState<Audit[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const token = getToken();
if (!token) return;
Promise.all([clientsApi.list(token), auditsApi.list(token)])
.then(([clients, audits]) => {
setStats({
totalClients: clients.length,
totalAudits: audits.length,
auditsEnCours: audits.filter((a) => a.statut === "en_cours").length,
auditsTermines: audits.filter((a) => a.statut === "termine").length,
});
setRecentAudits(audits.slice(0, 5));
})
.finally(() => setLoading(false));
}, []);
const statCards = [
{ label: "Clients", value: stats.totalClients, icon: Users, color: "text-blue-600" },
{ label: "Audits total", value: stats.totalAudits, icon: ClipboardList, color: "text-purple-600" },
{ label: "En cours", value: stats.auditsEnCours, icon: ShieldAlert, color: "text-orange-500" },
{ label: "Terminés", value: stats.auditsTermines, icon: CheckCircle, color: "text-green-600" },
];
const statutLabels: Record<string, string> = {
planifie: "Planifié",
en_cours: "En cours",
termine: "Terminé",
annule: "Annulé",
};
const statutColors: Record<string, string> = {
planifie: "text-blue-600 bg-blue-50",
en_cours: "text-orange-600 bg-orange-50",
termine: "text-green-600 bg-green-50",
annule: "text-gray-500 bg-gray-50",
};
return (
<div className="flex flex-col h-full">
<Header title="Tableau de bord" />
<div className="flex-1 p-6 space-y-6">
{/* Stats cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{statCards.map(({ label, value, icon: Icon, color }) => (
<Card key={label}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
<Icon className={`h-5 w-5 ${color}`} />
</CardHeader>
<CardContent>
<p className="text-3xl font-bold">
{loading ? <span className="animate-pulse text-muted-foreground"></span> : value}
</p>
</CardContent>
</Card>
))}
</div>
{/* Recent audits */}
<Card>
<CardHeader>
<CardTitle className="text-base">Audits récents</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<p className="text-sm text-muted-foreground">Chargement...</p>
) : recentAudits.length === 0 ? (
<p className="text-sm text-muted-foreground">Aucun audit pour le moment.</p>
) : (
<div className="space-y-3">
{recentAudits.map((audit) => (
<div key={audit.id} className="flex items-center justify-between py-2 border-b last:border-0">
<div>
<p className="font-medium text-sm">{audit.nom}</p>
<p className="text-xs text-muted-foreground">
{new Date(audit.createdAt).toLocaleDateString("fr-FR")}
</p>
</div>
<span
className={`text-xs font-medium px-2 py-1 rounded-full ${statutColors[audit.statut]}`}
>
{statutLabels[audit.statut]}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { Sidebar } from "@/components/layout/sidebar";
import { getToken } from "@/lib/auth";
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const router = useRouter();
useEffect(() => {
if (!getToken()) {
router.replace("/login");
}
}, [router]);
return (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<main className="flex-1 overflow-auto">{children}</main>
</div>
);
}

59
frontend/app/globals.css Normal file
View File

@@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 48%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

18
frontend/app/layout.tsx Normal file
View File

@@ -0,0 +1,18 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "AuditShield",
description: "Plateforme d'audit infrastructure et sécurité",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="fr">
<body className={inter.className}>{children}</body>
</html>
);
}

5
frontend/app/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function RootPage() {
redirect("/dashboard");
}

17
frontend/components.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

View File

@@ -0,0 +1,21 @@
"use client";
import { Bell } from "lucide-react";
import { Button } from "@/components/ui/button";
interface HeaderProps {
title: string;
}
export function Header({ title }: HeaderProps) {
return (
<header className="flex items-center justify-between border-b px-6 py-4 bg-background">
<h1 className="text-xl font-semibold">{title}</h1>
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon">
<Bell className="h-5 w-5" />
</Button>
</div>
</header>
);
}

View File

@@ -0,0 +1,64 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Shield, Users, ClipboardList, LayoutDashboard, LogOut } from "lucide-react";
import { cn } from "@/lib/utils";
import { removeToken } from "@/lib/auth";
import { useRouter } from "next/navigation";
const navItems = [
{ href: "/dashboard", label: "Tableau de bord", icon: LayoutDashboard },
{ href: "/clients", label: "Clients", icon: Users },
{ href: "/audits", label: "Audits", icon: ClipboardList },
];
export function Sidebar() {
const pathname = usePathname();
const router = useRouter();
function handleLogout() {
removeToken();
router.push("/login");
}
return (
<aside className="flex h-screen w-64 flex-col border-r bg-card">
{/* Logo */}
<div className="flex items-center gap-2 px-6 py-5 border-b">
<Shield className="h-7 w-7 text-primary" />
<span className="text-xl font-bold">AuditShield</span>
</div>
{/* Navigation */}
<nav className="flex-1 px-3 py-4 space-y-1">
{navItems.map(({ href, label, icon: Icon }) => (
<Link
key={href}
href={href}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
pathname.startsWith(href)
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<Icon className="h-4 w-4" />
{label}
</Link>
))}
</nav>
{/* Logout */}
<div className="px-3 py-4 border-t">
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
>
<LogOut className="h-4 w-4" />
Déconnexion
</button>
</div>
</aside>
);
}

View File

@@ -0,0 +1,32 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary: "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
critique: "border-transparent bg-red-600 text-white",
important: "border-transparent bg-orange-500 text-white",
modere: "border-transparent bg-yellow-500 text-white",
faible: "border-transparent bg-green-500 text-white",
},
},
defaultVariants: { variant: "default" },
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,48 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,55 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-lg border bg-card text-card-foreground shadow-sm", className)}
{...props}
/>
)
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-2xl font-semibold leading-none tracking-tight", className)}
{...props}
/>
)
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
)
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View File

@@ -0,0 +1,21 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
});
Input.displayName = "Input";
export { Input };

View File

@@ -0,0 +1,20 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
);
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };

141
frontend/lib/api.ts Normal file
View File

@@ -0,0 +1,141 @@
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
type RequestOptions = {
method?: string;
body?: unknown;
token?: string;
};
async function request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = "GET", body, token } = options;
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(`${API_BASE}/api${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const error = await res.json().catch(() => ({ detail: "Erreur réseau" }));
throw new Error(error.detail ?? "Erreur inconnue");
}
if (res.status === 204) return undefined as T;
return res.json();
}
// Auth
export const authApi = {
login: (email: string, password: string) => {
const form = new URLSearchParams({ username: email, password });
return fetch(`${API_BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form.toString(),
}).then((r) => {
if (!r.ok) throw new Error("Identifiants incorrects");
return r.json() as Promise<{ access_token: string; token_type: string }>;
});
},
me: (token: string) => request<User>("/auth/me", { token }),
};
// Clients
export const clientsApi = {
list: (token: string) => request<Client[]>("/clients/", { token }),
get: (id: number, token: string) => request<Client>(`/clients/${id}`, { token }),
create: (data: ClientCreate, token: string) =>
request<Client>("/clients/", { method: "POST", body: data, token }),
update: (id: number, data: Partial<ClientCreate>, token: string) =>
request<Client>(`/clients/${id}`, { method: "PATCH", body: data, token }),
delete: (id: number, token: string) =>
request<void>(`/clients/${id}`, { method: "DELETE", token }),
};
// Audits
export const auditsApi = {
list: (token: string, clientId?: number) =>
request<Audit[]>(`/audits/${clientId ? `?client_id=${clientId}` : ""}`, { token }),
get: (id: number, token: string) => request<AuditDetail>(`/audits/${id}`, { token }),
create: (data: AuditCreate, token: string) =>
request<Audit>("/audits/", { method: "POST", body: data, token }),
update: (id: number, data: Partial<AuditCreate>, token: string) =>
request<Audit>(`/audits/${id}`, { method: "PATCH", body: data, token }),
};
// Types
export type User = {
id: number;
email: string;
fullName: string;
isActive: boolean;
isAdmin: boolean;
};
export type Client = {
id: number;
nom: string;
contact: string | null;
email: string | null;
telephone: string | null;
notes: string | null;
createdAt: string;
};
export type ClientCreate = {
nom: string;
contact?: string;
email?: string;
telephone?: string;
notes?: string;
};
export type Audit = {
id: number;
clientId: number;
nom: string;
statut: "planifie" | "en_cours" | "termine" | "annule";
dateDebut: string | null;
dateFin: string | null;
scoreGlobal: number | null;
createdAt: string;
};
export type AuditCreate = {
clientId: number;
nom: string;
dateDebut?: string;
};
export type AuditDetail = Audit & {
cibles: Cible[];
vulnerabilites: Vulnerabilite[];
};
export type Cible = {
id: number;
auditId: number;
type: "ip" | "domaine" | "subnet";
valeur: string;
validee: boolean;
};
export type Vulnerabilite = {
id: number;
auditId: number;
criticite: "critique" | "important" | "modere" | "faible";
titre: string;
description: string;
recommandation: string;
cve: string | null;
cvssScore: number | null;
cible: string | null;
};

20
frontend/lib/auth.ts Normal file
View File

@@ -0,0 +1,20 @@
"use client";
const TOKEN_KEY = "auditshield_token";
export function getToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
}
export function removeToken(): void {
localStorage.removeItem(TOKEN_KEY);
}
export function isAuthenticated(): boolean {
return !!getToken();
}

6
frontend/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

15
frontend/next.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
async rewrites() {
return [
{
source: "/api/:path*",
destination: `${process.env.NEXT_PUBLIC_API_URL ?? "http://backend:8000"}/api/:path*`,
},
];
},
};
export default nextConfig;

45
frontend/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "auditshield-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"next": "14.2.3",
"react": "^18",
"react-dom": "^18",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-badge": "^1.0.0",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-toast": "^1.1.5",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.378.0",
"tailwind-merge": "^2.3.0",
"tailwindcss-animate": "^1.0.7",
"jose": "^5.2.4",
"js-cookie": "^3.0.5"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"@types/js-cookie": "^3.0.6",
"typescript": "^5",
"tailwindcss": "^3.4.1",
"postcss": "^8",
"autoprefixer": "^10.0.1",
"eslint": "^8",
"eslint-config-next": "14.2.3"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,73 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./lib/**/*.{ts,tsx}",
],
theme: {
container: {
center: true,
padding: "2rem",
screens: { "2xl": "1400px" },
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
};
export default config;

22
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

211
plan.md Normal file
View File

@@ -0,0 +1,211 @@
# AuditShield — Plan de développement
## Vue d'ensemble
Outil d'audit infrastructure et sécurité pour clients MSP.
Scans réseau + vulnérabilités + pentest → rapports PDF compréhensibles pour non-techniciens.
---
## ✅ Phase 1 — Socle (terminée)
> Branche : `feature/phase-1-socle` → mergée dans `dev`
### Ce qui a été créé
**Backend (FastAPI + SQLAlchemy)**
- `backend/core/` — config (pydantic-settings), database (SQLAlchemy 2.0), security (JWT + bcrypt)
- `backend/models/` — 7 modèles : User, Client, Audit, Cible, Vulnérabilité, Action + TimestampMixin
- `backend/schemas/` — Pydantic v2 : UserCreate/Read, ClientCreate/Update/Read, AuditCreate/Update/Read/Detail, CibleCreate/Read, VulnerabiliteCreate/Read, ActionCreate/Update/Read
- `backend/api/auth.py` — POST /register, POST /login (OAuth2), GET /me
- `backend/api/clients.py` — CRUD complet (GET list, POST, GET/{id}, PATCH/{id}, DELETE/{id})
- `backend/api/audits.py` — CRUD audit + nested : cibles (add/valider), vulnérabilités (add), actions (add/update)
- `backend/main.py` — FastAPI app, CORS (localhost:3000), rate limiting (slowapi), docs /api/docs en DEBUG
- `backend/alembic/` — migrations configurées (env.py prêt avec models importés)
- `backend/tests/test_auth.py` — test async register + login avec SQLite in-memory
**Frontend (Next.js 14 App Router)**
- Route groups : `(auth)` pour login, `(dashboard)` pour pages protégées
- `app/(auth)/login/page.tsx` — formulaire login avec gestion JWT
- `app/(dashboard)/layout.tsx` — guard auth (redirect /login), layout sidebar + main
- `app/(dashboard)/dashboard/page.tsx` — 4 stat cards + 5 audits récents
- `app/(dashboard)/clients/page.tsx` — grille clients avec recherche
- `app/(dashboard)/audits/page.tsx` — liste audits avec badges statut + recherche
- `components/layout/sidebar.tsx` — navigation (Dashboard, Clients, Audits) + déconnexion
- `components/ui/` — Button (CVA variants), Card, Input, Label, Badge (+ variantes criticité)
- `lib/api.ts` — client fetch générique typé : authApi, clientsApi, auditsApi
- `lib/auth.ts` — helpers localStorage pour token JWT
**Infrastructure**
- `docker/docker-compose.yml` — 4 services : postgres:16, redis:7, backend, frontend
- `docker/docker-compose.prod.yml` — images registry + labels Traefik + Let's Encrypt
- `backend/Dockerfile` — Python 3.11-slim + WeasyPrint deps + alembic upgrade
- `frontend/Dockerfile` — multi-stage Node 20 (deps → builder → runner standalone)
- `.env.example` — toutes les variables documentées
### Décisions techniques
| Décision | Raison |
|----------|--------|
| SQLAlchemy 2.0 avec type hints Mapped[] | API moderne, meilleure inference TypeScript équivalente côté Python |
| Pydantic v2 séparé des modèles ORM | Découplage validation/persistance, évite circular imports |
| JWT HS256 + 24h expiration | Simple à implémenter, stateless, suffisant pour MVP |
| slowapi pour rate limiting | Compatible FastAPI/Starlette, minimal |
| TimestampMixin | DRY pour created_at/updated_at sur tous les modèles |
| Cascade delete | Intégrité référentielle (supprimer client → supprime tout) |
| Celery + Redis (infra seulement) | Prêt pour les jobs asynchrones de scan (Phase 2) |
| Next.js App Router + route groups | Layouts différents auth/dashboard sans duplication |
| shadcn/ui + Radix | Accessibilité, composants non-opinionnés, facilement personnalisables |
| JWT dans localStorage | Simple pour MVP — à migrer vers HttpOnly cookie en Phase 3 |
| camelCase pour types TS, snake_case en DB | Convention JS vs convention Python/SQL |
| Rewrites next.config.ts vers backend | Pas de CORS en prod, tout passe par Next.js |
### État fonctionnel
- ✅ Register/Login utilisateur avec JWT
- ✅ CRUD clients complet
- ✅ CRUD audits + cibles + vulnérabilités + actions
- ✅ Dashboard avec stats temps réel
- ✅ Pages liste clients et audits avec recherche
- ✅ Guard auth côté frontend
- ✅ Docker dev + prod prêt
- ⏳ Pages de formulaire (création/édition) non créées
- ⏳ Pages détail client et audit non créées
- ⏳ Scanners : dossier vide (placeholder Phase 2)
- ⏳ Génération PDF : dossier vide (placeholder Phase 4)
---
## 🔲 Phase 2 — Scanners réseau (Nmap)
> Branche : `feature/phase-2-nmap`
### Objectif
Permettre le lancement d'un scan Nmap sur des cibles validées, afficher les résultats en temps réel, et les persister en base.
### Tâches backend
- [ ] **Celery worker** — configurer `backend/celery_app.py`, worker Docker dans docker-compose
- [ ] **Scanner Nmap**`backend/scanners/nmap.py`
- Wrapper autour de `python-nmap`
- Parse les résultats : ports ouverts, services, OS
- Vérification que la cible est `validee = True` avant tout scan
- Log de chaque scan en BDD (model `ScanLog`)
- [ ] **Model ScanLog**`backend/models/scan_log.py`
- audit_id, cible_id, type_scan, statut, résultat (JSON), durée
- [ ] **Tâche Celery**`backend/scanners/tasks.py`
- `task_scan_nmap(audit_id, cible_ids)` — async, met à jour statut
- Retour des résultats via polling ou WebSocket
- [ ] **Routes scan**`backend/api/scans.py`
- `POST /audits/{audit_id}/scans/nmap` — lance le scan (en background)
- `GET /audits/{audit_id}/scans` — liste les scans passés
- `GET /audits/{audit_id}/scans/{scan_id}` — détail d'un scan
- [ ] **Parsing Nmap → Vulnérabilités** — déduire des vulnérabilités simples (ports dangereux, services obsolètes)
### Tâches frontend
- [ ] **Page détail audit**`app/(dashboard)/audits/[id]/page.tsx`
- Affiche : infos audit, liste cibles (avec bouton "Valider"), liste vulnérabilités, liste actions
- Bouton "Lancer un scan Nmap"
- Statut du scan en temps réel (polling GET /scans)
- [ ] **Page détail client**`app/(dashboard)/clients/[id]/page.tsx`
- Infos client + liste des audits du client
- Bouton "Nouvel audit"
- [ ] **Formulaire nouveau client**`app/(dashboard)/clients/nouveau/page.tsx`
- [ ] **Formulaire nouvel audit**`app/(dashboard)/audits/nouveau/page.tsx`
- [ ] **Formulaire ajout cible** — Modal ou inline dans détail audit
- [ ] **Composant ScanStatus** — affiche statut scan (en cours / terminé / erreur)
- [ ] **Toast notifications** — feedback utilisateur (succès, erreur)
### Tâches infra
- [ ] Ajouter service `worker` dans docker-compose (image backend + command: celery worker)
- [ ] Ajouter `python-nmap` dans requirements.txt
- [ ] Première migration Alembic : `alembic revision --autogenerate -m "init"`
---
## 🔲 Phase 3 — Vulnérabilités avancées + Actions
> Branche : `feature/phase-3-vulnerabilites`
### Objectif
Enrichissement des vulnérabilités avec CVE, scoring CVSS automatique, workflow de gestion des actions, et calcul du score global d'audit.
### Tâches backend
- [ ] **Intégration CVE/NVD**`backend/scanners/cve.py`
- Lookup CVE via NVD API (gratuit)
- Traduction automatique en langage clair (résumé simplifié)
- Auto-remplissage criticité depuis CVSS score
- [ ] **Calcul score global audit** — méthode sur le model Audit
- Pondération : critique × 4, important × 3, modéré × 2, faible × 1
- Normalisé sur 10, mis à jour à chaque vulnérabilité ajoutée
- [ ] **OpenVAS optionnel**`backend/scanners/openvas.py`
- Wrapper GVM (Greenbone Vulnerability Management)
- Si disponible, scan plus profond
- [ ] **Notifications** — model `Notification`, endpoint SSE ou WebSocket
- Alertes nouvelles vulnérabilités critiques
- Changements de statut d'action
### Tâches frontend
- [ ] **Page détail vulnérabilité**`app/(dashboard)/audits/[id]/vulnerabilites/[vulnId]/page.tsx`
- Affiche : titre, criticité (badge coloré), CVE, CVSS, description, recommandation
- Liste des actions liées + bouton "Ajouter une action"
- Formulaire d'action inline
- [ ] **Composant CriticiteBar** — représentation visuelle du score global
- [ ] **Page gestion des actions** — tableau kanban simple (Ouvert → En cours → Résolu)
- [ ] **Filtres et tri** — sur les listes de vulnérabilités (par criticité, statut)
- [ ] **Pagination** — composant `<Pagination>` réutilisable
---
## 🔲 Phase 4 — Rapports PDF + Dashboard avancé
> Branche : `feature/phase-4-rapports`
### Objectif
Génération de rapports PDF professionnels compréhensibles par un dirigeant non-technique, et dashboard enrichi avec graphiques.
### Tâches backend
- [ ] **Moteur PDF**`backend/reports/pdf.py`
- WeasyPrint + Jinja2 templates HTML
- Template rapport : page de garde, résumé exécutif, score global, vulnérabilités par criticité, recommandations, plan d'actions
- Langage client : pas de jargon technique, CVE traduits en langage clair
- [ ] **Templates HTML**`backend/reports/templates/`
- `rapport_audit.html` — template principal
- `_vulnerabilite.html` — bloc par vulnérabilité
- CSS intégré (compatible WeasyPrint)
- [ ] **Route export**`GET /audits/{audit_id}/rapport.pdf`
- Génère et streame le PDF
- Header `Content-Disposition: attachment`
- [ ] **Tâche Celery** — génération PDF asynchrone pour audits longs
### Tâches frontend
- [ ] **Bouton "Exporter PDF"** — dans la page détail audit
- [ ] **Dashboard avancé** — graphiques :
- Répartition vulnérabilités par criticité (donut chart)
- Évolution des audits dans le temps (line chart)
- Taux de résolution des actions (progress bar)
- Utiliser `recharts` ou `chart.js`
- [ ] **Page rapports**`app/(dashboard)/rapports/page.tsx`
- Liste des rapports générés, téléchargement
- [ ] **Mode impression** — CSS @media print pour la page détail audit
---
## Prochaine étape
```bash
git checkout dev
git checkout -b feature/phase-2-nmap
```
Commencer par :
1. Configurer Celery dans `docker-compose.yml` (service worker)
2. Créer `backend/models/scan_log.py`
3. Créer `backend/scanners/nmap.py`
4. Générer la première migration Alembic
5. Créer les pages frontend manquantes (détail audit, formulaires)