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>
This commit is contained in:
2026-03-21 17:16:12 +01:00
parent 1ff3c15ea9
commit 0fe1a1b751
60 changed files with 2308 additions and 6 deletions

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()