diff --git a/.env.docker b/.env.docker new file mode 100644 index 00000000..23e4ce31 --- /dev/null +++ b/.env.docker @@ -0,0 +1,109 @@ +################################################## +# Application +################################################## +APP_NAME=Deming +APP_ENV=production +APP_FORCE_HTTPS=false +APP_KEY= +APP_DEBUG=true +APP_URL=http://deming.yourdomain.com +APP_TIMEZONE='Europe/Paris' +APP_EDITOR= + +################################################## +# Database +################################################## +DB_CONNECTION=mysql +DB_HOST=mysql +DB_PORT=3306 +DB_DATABASE=deming +DB_USERNAME=deming_user +DB_PASSWORD=demPasssword-123 + +LOG_CHANNEL=stack + +BROADCAST_DRIVER=log +CACHE_DRIVER=file +QUEUE_CONNECTION=sync +SESSION_DRIVER=file +SESSION_LIFETIME=120 + +################################################## +# Mail +################################################## +MAIL_HOST='smtp.localhost' +MAIL_PORT=2525 +MAIL_AUTH=true +MAIL_SMTP_SECURE='ssl' # 'ssl', 'tls' or null +MAIL_SMTP_AUTO_TLS=false # true / false +MAIL_USERNAME= +MAIL_PASSWORD= + +# MAIL_DKIM_DOMAIN = 'admin.local'; +# MAIL_DKIM_PRIVATE = '/path/to/private/key'; +# MAIL_DKIM_SELECTOR = 'default'; # Match your DKIM DNS selector +# MAIL_DKIM_PASSPHRASE = ''; # Only if your key has a passphrase + +################################################## +# LDAP +################################################## +# - If LDAP_ENABLED=true => try LDAP; on success, log the mapped local user in. +# - If LDAP fails and LDAP_FALLBACK_LOCAL=true => try local DB credentials. +# - If LDAP_ENABLED=false => only local DB credentials. + +LDAP_ENABLED=false +LDAP_FALLBACK_LOCAL=true +LDAP_AUTO_PROVISION=false + +# Config +LDAP_LOGGING=false +LDAP_CONNECTION=default +LDAP_HOST=127.0.0.1 +LDAP_USERNAME="cn=admin,dc=example,dc=org" +LDAP_PASSWORD=admin +LDAP_PORT=389 +LDAP_BASE_DN="dc=example,dc=org" +LDAP_TIMEOUT=5 +LDAP_SSL=false +LDAP_TLS=false + +# Candidate attributes to identify the username entered in the form +# Order matters: the first match wins. +# OpenLDAP: uid, cn, mail ; AD: sAMAccountName, userPrincipalName, mail +LDAP_LOGIN_ATTRIBUTES="uid,cn,mail,sAMAccountName,userPrincipalName" + +# Match user group or null for any group +LDAP_GROUP= + +################################################## +# Socialite +################################################## + +# List of socialite providers separated by a space. Possible value : keycloak, oidc +SOCIALITE_PROVIDERS="" + +KEYCLAOK_DISPLAY_NAME="Keycloak" +KEYCLOAK_ALLOW_CREATE_USER=false +KEYCLOAK_ALLOW_UPDATE_USER=false +KEYCLOAK_DEFAULT_ROLE="auditee" +KEYCLOAK_ROLE_CLAIM="resource_access.deming.roles.0" +KEYCLOAK_ADDITIONAL_SCOPES="roles" + +KEYCLOAK_CLIENT_ID=deming +KEYCLOAK_CLIENT_SECRET=secret +KEYCLOAK_REDIRECT_URI=${APP_URL}auth/callback/keycloak +KEYCLOAK_BASE_URL=https://keycloak.local +KEYCLOAK_REALM=main + +OIDC_DISPLAY_NAME="Generic OIDC" +OIDC_ALLOW_CREATE_USER=false +OIDC_ALLOW_UPDATE_USER=false +OIDC_DEFAULT_ROLE="auditee" +OIDC_ROLE_CLAIM="" +OIDC_ADDITIONAL_SCOPES="deming_role" + +OIDC_CLIENT_ID=deming +OIDC_CLIENT_SECRET=deming +OIDC_BASE_URL=http://auth.lan +OIDC_SUFFIX="" +OIDC_REDIRECT_URI=${APP_URL}auth/callback/oidc diff --git a/.env.example b/.env.example index d4c5093f..20f9eb4c 100644 --- a/.env.example +++ b/.env.example @@ -2,13 +2,13 @@ # Application ################################################## APP_NAME=Deming -APP_ENV=production +APP_ENV=local APP_FORCE_HTTPS=false -APP_KEY= +APP_KEY=base64:zEAYO9a2F2XWcbgqNitf9gP/U8Qu6qIt95zy3uBYwwk= APP_DEBUG=true -APP_URL=http://deming.yourdomain.com +APP_URL= APP_TIMEZONE='Europe/Paris' -APP_EDITOR= +APP_BANNER_TEST= ################################################## # Database @@ -50,7 +50,6 @@ MAIL_PASSWORD= # - If LDAP_ENABLED=true => try LDAP; on success, log the mapped local user in. # - If LDAP fails and LDAP_FALLBACK_LOCAL=true => try local DB credentials. # - If LDAP_ENABLED=false => only local DB credentials. - LDAP_ENABLED=false LDAP_FALLBACK_LOCAL=true LDAP_AUTO_PROVISION=false @@ -62,23 +61,15 @@ LDAP_HOST=127.0.0.1 LDAP_USERNAME="cn=admin,dc=example,dc=org" LDAP_PASSWORD=admin LDAP_PORT=389 -LDAP_BASE_DN="dc=example,dc=org" +LDAP_BASE_DN="cn=users,dc=example,dc=org" LDAP_TIMEOUT=5 LDAP_SSL=false LDAP_TLS=false - -# Candidate attributes to identify the username entered in the form -# Order matters: the first match wins. -# OpenLDAP: uid, cn, mail ; AD: sAMAccountName, userPrincipalName, mail -LDAP_LOGIN_ATTRIBUTES="uid,cn,mail,sAMAccountName,userPrincipalName" - -# Match user group or null for any group -LDAP_GROUP= +LDAP_LOGIN_ATTRIBUTES="cn" ################################################## # Socialite ################################################## - # List of socialite providers separated by a space. Possible value : keycloak, oidc SOCIALITE_PROVIDERS="" @@ -105,8 +96,5 @@ OIDC_ADDITIONAL_SCOPES="deming_role" OIDC_CLIENT_ID=deming OIDC_CLIENT_SECRET=deming OIDC_BASE_URL=http://auth.lan -OIDC_SUFFIX="" -OIDC_USE_ID_TOKEN=false # true pour décoder le JWT -OIDC_JWT_ALG=RS256 # RS256 ou HS256. utile uniquement avec OIDC_USE_ID_TOKEN=true -OIDC_JWT_SECRET_OR_KEY="" # secret pour HS256 ou clé au format PEM pour RS256 OIDC_REDIRECT_URI=${APP_URL}auth/callback/oidc + diff --git a/Docker.md b/Docker.md new file mode 100644 index 00000000..d5344b42 --- /dev/null +++ b/Docker.md @@ -0,0 +1,650 @@ +# Deming — Docker Deployment Guide + +> **Deming** is an open-source ISMS management tool (ISO 27001 / NIS 2) built on Laravel. +> This guide covers everything you need to run it with Docker Compose. + +--- + +## Table of Contents + +1. [Prerequisites](#prerequisites) +2. [Architecture Overview](#architecture-overview) +3. [Quick Start](#quick-start) +4. [Environment Configuration](#environment-configuration) +5. [Build](#build) +6. [Initialization Variables](#initialization-variables) +7. [Start](#start) +8. [Stop](#stop) +9. [Ports](#ports) +10. [First Connection](#first-connection) +11. [Logs](#logs) +12. [Shell Access](#shell-access) +13. [Database Operations](#database-operations) +14. [Persistent Volumes](#persistent-volumes) +15. [Production Considerations](#production-considerations) +16. [Troubleshooting](#troubleshooting) + +--- + +## Prerequisites + +| Requirement | Minimum version | +|---|---| +| Docker Engine | 24.x | +| Docker Compose plugin | v2.x (`docker compose`) | +| Git | any recent version | +| Free RAM | 512 MB | +| Free disk | 2 GB | + +> **Note:** The legacy `docker-compose` (v1, Python) is **not** supported. Use `docker compose` (v2, Go plugin). + +--- + +## Architecture Overview + +The stack is composed of two services orchestrated by Docker Compose: + +``` + ┌─────────────────────────────────────────────┐ + │ Docker network │ + │ │ + Host :8000 ──────►│ nginx:80 ──► artisan serve:8000 (PHP) │ + │ │ │ + │ ┌────────▼────────┐ │ + │ │ mysql:3306 │ │ + │ │ (internal) │ │ + │ └─────────────────┘ │ + └─────────────────────────────────────────────┘ +``` + +| Service | Role | Image | +|---|---|---| +| `deming` | Nginx (reverse proxy) + Laravel (`artisan serve`) | Debian + PHP 8.3 | +| `mysql` | Database | MySQL 9.5 | + +**Important:** The web layer is **nginx → `php artisan serve`**, not nginx → php-fpm. +Nginx listens on port 80 inside the container and proxies all requests to `artisan serve` +on port 8000 (internal). From the host, the application is accessible on the port mapped +to container port 80. + +--- + +## Quick Start + +```bash +# 1. Clone the repository +git clone https://github.com/dbarzin/deming.git +cd deming + +# 2. Create the environment file +cp .env.example .env + +# 3. Set mandatory DB variables for Docker +sed -i 's/^DB_CONNECTION=.*/DB_CONNECTION=mysql/' .env +sed -i 's/^DB_HOST=.*/DB_HOST=mysql/' .env + +# 4. Start the stack (builds the image on first run) +docker compose up +``` + +The application will be available at **http://localhost:8000** after initialization +completes (≈ 60–90 s on first run). + +--- + +## Environment Configuration + +The `.env` file is mounted as a volume into the container — +**all configuration happens there**, not in `docker-compose.yml`. + +### Mandatory variables for Docker + +```dotenv +# Must be the Docker Compose service name — NOT 127.0.0.1 +DB_CONNECTION=mysql +DB_HOST=mysql +DB_PORT=3306 +DB_DATABASE=deming +DB_USERNAME=deming_user +DB_PASSWORD=your_password +``` + +### Full `.env` reference + +```dotenv +# ── Application ────────────────────────────────────────── +APP_NAME=Deming +APP_ENV=local # lease it to local for automatic migrations +APP_KEY= # Auto-generated on first boot if empty +APP_DEBUG=true # false in production +APP_URL=http://localhost:8000 +APP_BANNER_TEST=false # add a warning banner for test environeent + +# ── Database ───────────────────────────────────────────── +DB_CONNECTION=mysql +DB_HOST=mysql # ← Docker service name, never 127.0.0.1 +DB_PORT=3306 +DB_DATABASE=deming +DB_USERNAME=deming_user +DB_PASSWORD=your_password + +# ── Mail (optional) ────────────────────────────────────── +MAIL_HOST=smtp.localhost +MAIL_PORT=2525 + +# ── LDAP (optional) ────────────────────────────────────── +LDAP_ENABLED=false +``` + +### MySQL root password + +The image uses `MYSQL_RANDOM_ROOT_PASSWORD=1` — a random root password is generated at +first start and printed in the MySQL logs. The application never uses the root account; +only `DB_USERNAME` / `DB_PASSWORD` matter. `MYSQL_ROOT_PASSWORD` is not required. + +--- + +## Build + +The Docker image is built automatically on first `docker compose up`. To rebuild manually: + +```bash +# Standard rebuild (uses cache) +docker compose build deming + +# Force full rebuild (after Dockerfile or script changes) +docker compose build --no-cache deming +``` + +### What the build does + +1. Starts from a Debian Bookworm + PHP 8.3 base image +2. Installs Nginx, required PHP extensions, and Composer +3. Clones the Deming repository into `/var/www/deming` +4. Copies the Nginx vhost (`docker/deming.conf`) to `/etc/nginx/conf.d/deming.conf` +5. Copies initialization scripts (`entrypoint.sh`, `initialdb.sh`, etc.) to `/etc/` +6. Installs PHP dependencies via Composer + +> After any change to `Dockerfile`, `entrypoint.sh`, `initialdb.sh` or other Docker +> scripts, always rebuild with `--no-cache` to ensure the new version is used: +> ```bash +> docker compose down +> docker compose build --no-cache deming +> docker compose up +> ``` + +--- + +## Initialization Variables + +These variables in `docker-compose.yml` control the first-run initialization: + +| Variable | Values | Description | +|---|---|---| +| `INITIAL_DB` | `EN` / `FR` | Run migrations and seed the database in English or French | +| `UPLOAD_DB_ISO27001` | `EN` / `FR` | Import the ISO 27001 control framework | +| `USE_DEMO_DATA` | `1` / unset | Generate demo controls, measures and audit data | +| `RESET_DB` | `EN` / `FR` | **⚠️ Wipe and recreate the entire database** | +| `DB_SLEEP` | integer (seconds) | Extra wait before migration attempts (default: 10) | + +### Recommended lifecycle + +```yaml +# docker-compose.yml — first run +environment: + - INITIAL_DB=FR + - UPLOAD_DB_ISO27001=FR + - USE_DEMO_DATA=1 + - DB_SLEEP=10 + - TZ=Europe/Paris + - APP_FORCE_HTTPS=false +``` + +```yaml +# docker-compose.yml — after first successful start (optimized) +environment: + - TZ=Europe/Paris + - APP_FORCE_HTTPS=false + # All initialization variables removed — no unnecessary work on restart +``` + +> **Never** leave `RESET_DB` enabled after the first run — it wipes all data on every restart. +> **Never** set `APP_ENV` here — always set it in `.env`. + +--- + +## Start + +### Foreground (logs visible in terminal) + +```bash +docker compose up +``` + +### Background (detached mode) + +```bash +docker compose up -d +``` + +### Check status + +```bash +docker compose ps +``` + +Expected output when healthy: + +``` +NAME IMAGE COMMAND SERVICE STATUS PORTS +deming-deming-1 deming-deming "/opt/entrypoint.sh" deming Up 9000/tcp, 0.0.0.0:8000->80/tcp +deming-mysql-1 mysql:9.5 "docker-entrypoint…" mysql Up (healthy) 3306/tcp, 33060/tcp +``` + +The `(healthy)` status on MySQL confirms the healthcheck passed before Deming started. + +--- + +## Stop + +### Stop containers (preserve volumes and images) + +```bash +docker compose stop +``` + +### Stop and remove containers (preserve volumes) + +```bash +docker compose down +``` + +### Stop, remove containers **and** volumes (⚠️ destroys all data) + +```bash +docker compose down -v +``` + +### Restart a single service + +```bash +docker compose restart deming +``` + +--- + +## Ports + +| Host port | Container port | Service | Description | +|---|---|---|---| +| **8000** | **80** | `deming` | Web application — Nginx entry point | +| *(internal)* | 8000 | `deming` | `artisan serve` — proxied by Nginx, not directly accessible | +| *(not exposed)* | 3306 | `mysql` | MySQL — internal only | + +The port mapping in `docker-compose.yml` must be: + +```yaml +services: + deming: + ports: + - "8000:80" # host:container — nginx listens on container port 80 +``` + +> ⚠️ **Common mistake:** `80:8000` is wrong (it maps host port 80 to container port 8000 +> where nothing listens from outside). Always use `HOST_PORT:80`. + +### Change the host port + +```yaml +ports: + - "80:80" # serve on http://localhost + - "8080:80" # serve on http://localhost:8080 +``` + +### Expose MySQL for a DB client (dev only) + +```yaml +mysql: + ports: + - "3306:3306" +``` + +--- + +## First Connection + +Once the stack is running and the logs show `Generate test data`, open: + +``` +http://localhost:8000 +``` + +### Default credentials + +| Role | Email | Password | +|---|---|---| +| Administrator | `admin@admin.com` | `password` | + +> **Important:** Change the default password immediately after first login via +> **Settings → My profile → Change password**. + +### Role hierarchy + +| Role | Access level | +|---|---| +| Admin | Full access, user management | +| User | Controls, measures, actions | +| Auditee | Read-only on assigned controls | +| Auditor | Audit workflow access | + +--- + +## Logs + +### All services (follow mode) + +```bash +docker compose logs -f +``` + +### Application logs only + +```bash +docker compose logs -f deming +``` + +### Database logs only + +```bash +docker compose logs -f mysql +``` + +### Laravel application log + +```bash +docker compose exec deming tail -f /var/www/deming/storage/logs/laravel.log +``` + +### Nginx logs + +```bash +docker compose exec deming tail -f /var/log/nginx/access.log +docker compose exec deming tail -f /var/log/nginx/error.log +``` + +### Last N lines + +```bash +docker compose logs --tail=100 deming +``` + +### Normal startup sequence + +A healthy first-run startup produces logs in this order: + +``` +mysql-1 | ready for connections. Version: '9.5.0' +mysql-1 | [Healthcheck] OK +deming-1 | Waiting for MySQL to be ready... +deming-1 | MySQL is ready. +deming-1 | Waiting for 10 seconds before executing migration... +deming-1 | Initialize database +deming-1 | INFO Nothing to migrate. +deming-1 | INFO Seeding database. +deming-1 | INFO Database cleared. +deming-1 | INFO 103 lines inserted. +deming-1 | INFO 5 new domains created. +deming-1 | INFO Generate test data. +deming-1 | INFO Encryption keys generated successfully. +deming-1 | INFO New client created successfully. +deming-1 | Starting periodic command scheduler: cron. +deming-1 | [NOTICE] fpm is running, ready to handle connections +``` + +The `WARN Command cancelled` messages during seeding are normal — they are produced by +internal seeders asking for confirmation in production mode and are not fatal. + +--- + +## Shell Access + +### Open a bash shell in the app container + +```bash +docker compose exec deming bash +``` + +### Useful Artisan commands + +```bash +# Clear all caches +docker compose exec deming php artisan cache:clear +docker compose exec deming php artisan config:clear +docker compose exec deming php artisan view:clear + +# Run pending migrations +docker compose exec deming php artisan migrate --force + +# Show current environment +docker compose exec deming php artisan env + +# List all Artisan commands +docker compose exec deming php artisan list +``` + +--- + +## Database Operations + +### Access the MySQL CLI + +```bash +docker compose exec mysql mysql -u deming_user -pyour_password deming +``` + +### Backup the database + +```bash +docker compose exec mysql \ + mysqldump -u deming_user -pyour_password deming \ + > backup_$(date +%Y%m%d_%H%M%S).sql +``` + +### Restore a backup + +```bash +docker compose exec -T mysql \ + mysql -u deming_user -pyour_password deming \ + < backup_20250101_120000.sql +``` + +### Full reset (⚠️ destroys all data) + +Set `RESET_DB=FR` (or `EN`) in `docker-compose.yml`, then: + +```bash +docker compose down +docker compose up +``` + +Remove `RESET_DB` immediately after the reset completes. + +--- + +## Persistent Volumes + +| Volume | Container path | Contents | +|---|---|---| +| `deming_dbdata` | `/var/lib/mysql` | All database data | + +The `.env` file and `docker/custom/` files are bind-mounted from the host directory — +they survive container restarts and removals as long as the project directory exists. + +### List volumes + +```bash +docker volume ls | grep deming +``` + +### Backup the database volume + +```bash +docker compose exec mysql \ + mysqldump -u deming_user -pyour_password deming \ + > backup_$(date +%Y%m%d_%H%M%S).sql +``` + +--- + +## Production Considerations + +### 1. Remove initialization variables after first run + +```yaml +# Remove from docker-compose.yml environment after first successful start: +# - INITIAL_DB +# - UPLOAD_DB_ISO27001 +# - USE_DEMO_DATA +# - DB_SLEEP +``` + +### 2. Secure the `.env` file + +```bash +chmod 600 .env +``` + +Never commit `.env` to version control. + +### 3. Set `APP_ENV` in `.env` only — never in `docker-compose.yml` + +```dotenv +APP_ENV=production +APP_DEBUG=false +``` + +### 4. Enable automatic restarts + +```yaml +services: + deming: + restart: unless-stopped + mysql: + restart: unless-stopped +``` + +### 5. Add HTTPS with a reverse proxy + +Place Nginx or Traefik in front of the stack and terminate TLS there. +Update `APP_URL` and `APP_FORCE_HTTPS` in `.env`: + +```dotenv +APP_URL=https://deming.example.com +APP_FORCE_HTTPS=true +``` + +### 6. Keep MySQL internal + +Never expose port 3306 to the host in production. + +--- + +## Troubleshooting + +### Container loops on "Not ready, retrying" + +MySQL is reachable at the network level but Laravel cannot connect. +The most common cause is a wrong value in `.env`: + +```bash +docker compose exec deming grep '^DB_' .env +``` + +Both `DB_CONNECTION` and `DB_HOST` must be set to `mysql`: + +```dotenv +DB_CONNECTION=mysql # ← not 127.0.0.1 +DB_HOST=mysql # ← not 127.0.0.1 +``` + +### "APPLICATION IN PRODUCTION — Command cancelled" + +Seeders are blocked because `APP_ENV=production` is set in `docker-compose.yml`. +Remove it from `docker-compose.yml` and set it in `.env` instead. + +### No response on port 8000 — connection reset + +The port mapping is inverted. In `docker-compose.yml`: + +```yaml +ports: + - "8000:80" # ✅ correct — host 8000 → container nginx 80 + # - "80:8000" # ❌ wrong — host 80 → container port 8000 (nothing there) +``` + +### Container starts but exits silently after cron + +An initialization script exited with a non-zero code. Check: + +```bash +docker compose logs deming | tail -50 +``` + +The `|| echo "skipped"` guards in `entrypoint.sh` prevent optional scripts from +killing the startup. If a mandatory script fails, check its output for the root cause. + +### Nginx "conflicting server name" warning + +Two nginx configs both declare `server_name _;`. The Dockerfile should copy +`docker/deming.conf` to `deming.conf` (not `default.conf`). Rebuild to fix: + +```bash +docker compose down +docker compose build --no-cache deming +docker compose up +``` + +### sed: cannot rename — Device or resource busy + +`sed -i` cannot modify `.env` because it is a Docker bind mount. Never use `sed -i` +on the `.env` file from inside the container. Edit it on the host instead. + +### Reset everything and start fresh + +```bash +docker compose down -v +docker compose build --no-cache deming +docker compose up +``` + +### Diagnostic commands + +```bash +# Nginx config syntax check +docker compose exec deming nginx -t + +# Full active nginx configuration +docker compose exec deming nginx -T + +# Nginx config files present +docker compose exec deming find /etc/nginx/conf.d /etc/nginx/sites-enabled -type f + +# Check PHP version and key extensions +docker compose exec deming php -v +docker compose exec deming php -m | grep -E 'pdo|mbstring|xml|gd' + +# Laravel environment +docker compose exec deming php artisan env +``` + +--- + +## Useful References + +- **Project repository:** https://github.com/dbarzin/deming +- **Official documentation:** https://dbarzin.github.io/deming/ +- **API documentation:** https://dbarzin.github.io/deming/api/ +- **Issue tracker:** https://github.com/dbarzin/deming/issues +- **Discussions:** https://github.com/dbarzin/deming/discussions +- **License:** GPL-3.0 + diff --git a/Dockerfile b/Dockerfile index d99f8295..88f7a46a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,7 +30,7 @@ RUN mkdir -p /var/www/deming WORKDIR /var/www/deming RUN git clone https://www.github.com/dbarzin/deming . -RUN cp docker/deming.conf /etc/nginx/conf.d/default.conf +RUN cp docker/deming.conf /etc/nginx/conf.d/deming.conf RUN cp docker/userdemo.sh /etc/userdemo.sh COPY docker/resetdb.sh /etc/resetdb.sh RUN cp docker/uploadiso27001db.sh /etc/uploadiso27001db.sh diff --git a/ROADMAP.md b/ROADMAP.md index 18505b37..08b3f83e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,16 +4,18 @@ This document contains the evolutions planned in 2026 ## Important -* [ ] Risks register : https://github.com/dbarzin/deming/discussions/562 +* [x] Dashboard for auditees : https://github.com/dbarzin/deming/discussions/556 +* [x] Risks register : https://github.com/dbarzin/deming/discussions/562 +* [ ] Security exceptions : https://github.com/dbarzin/deming/issues/590 +* [ ] Configurable control scoring system * [ ] Export set of controls for audit * [ ] Send notifications to Slack with Laravel Notification Framework : https://github.com/dbarzin/deming/discussions/540 * [ ] Non-regression tests - - + ## Improvements +* [ ] Update documentation * [ ] Convert Markdown to ODT in reports -* [ ] Configure scoring system * [ ] Put configuration in database in place of PHP file (for Docker) * [ ] Import Security Controls from [OSCAL](https://pages.nist.gov/OSCAL/) diff --git a/app/Exports/RiskExport.php b/app/Exports/RiskExport.php new file mode 100644 index 00000000..fc3d0f92 --- /dev/null +++ b/app/Exports/RiskExport.php @@ -0,0 +1,103 @@ + ['font' => ['bold' => true], + 'alignment' => [ + 'wrapText' => true, + 'vertical' => 'top', + ], + ], + ]; + } + + public function columnFormats(): array + { + return [ + 'A' => NumberFormat::FORMAT_TEXT, + 'B' => NumberFormat::FORMAT_TEXT, + ]; + } + + public function columnWidths(): array + { + return [ + 'A' => 30, // Name + 'B' => 50, // Description + 'C' => 30, // Owner + 'D' => 10, // probability + 'E' => 30, // probability_comment + 'F' => 10, // impact + 'G' => 30, // impact_comment + 'H' => 10, // status + 'I' => 30, // status_comment + 'J' => 10, // review_frequency + 'K' => 30, // next_review_at + 'L' => 10, // exposure + 'M' => 10, // vulnerability + ]; + } + + public function map($risk): array + { + return [ + [ + $risk->name, + $risk->description, + $risk->owner?->name, + $risk->probability, + $risk->probability_comment, + $risk->impact, + $risk->impact_comment, + $risk->status, + $risk->status_comment, + $risk->review_frequency, + $risk->next_review_at?->format('Y-m-d'), + $risk->exposure, + $risk->vulnerability, + ], + ]; + } + + public function query(): Builder + { + return Risk::query()->with('owner')->orderBy('name'); + } +} diff --git a/app/Http/Controllers/AuditLogsController.php b/app/Http/Controllers/AuditLogsController.php index 06d30918..f72fbbda 100644 --- a/app/Http/Controllers/AuditLogsController.php +++ b/app/Http/Controllers/AuditLogsController.php @@ -73,6 +73,8 @@ public function history(string $type, int $id) $type = \App\Models\Action::class; } elseif ($type === 'user') { $type = \App\Models\User::class; + } elseif ($type === 'risk') { + $type = \App\Models\Risk::class; } else { abort(404, 'Not found'); } diff --git a/app/Http/Controllers/ControlController.php b/app/Http/Controllers/ControlController.php index 253ebde8..1926d06d 100644 --- a/app/Http/Controllers/ControlController.php +++ b/app/Http/Controllers/ControlController.php @@ -668,7 +668,7 @@ public function clone(Request $request) } // Get Control - $control = Control::find($request->id); + $control = Control::query()->find($request->id); // Workstation not found abort_if($control === null, Response::HTTP_NOT_FOUND, '404 Not Found'); @@ -1799,22 +1799,59 @@ public function tempo(Request $request) ); // get measures - if ($request->id !== null) { + if ($request->clause !== null) { // Find associate control - $measures = Measure::query()->where('clause', '=', $request->id)->get(); + if ($request->scope !== null) { + $measures = Measure::query() + ->where('clause', '=', $request->clause) + ->whereHas('controls', function ($q) { + $q->where('controls.status', '=', 2); + }) + ->whereHas('controls', function ($q) use ($request) { + $q->where('controls.scope', '=', $request->scope); + }) + ->with(['controls' => function ($q) use ($request) { + $q->where('controls.scope', '=', $request->scope); + }]) + ->get(); + } + else + $measures = Measure::query() + ->with('controls') + ->where('clause', '=', $request->clause) + ->whereHas('controls', function ($q) { + $q->where('controls.status', '=', 2); + }) + ->get(); + $scopes = DB::Table('measures') + ->select('scope') + ->join('control_measure', 'measures.id', '=', 'control_measure.measure_id') + ->join('controls', 'control_measure.control_id', '=', 'controls.id') + ->where('measures.clause', '=', $request->clause) + ->whereNotNull('controls.scope') + ->distinct() + ->orderby('scope') + ->pluck('scope'); } else { $measures = Collect(); + $scopes = Collect(); } $clauses = DB::Table('measures') ->select('clause') + ->join('control_measure', 'measures.id', '=', 'control_measure.measure_id') + ->join('controls', 'control_measure.control_id', '=', 'controls.id') + ->where('controls.status','=', 2) ->distinct() ->orderby('clause') ->pluck('clause'); + + // return view return view('radar.measures') ->with('clauses', $clauses) + ->with('scopes', $scopes) ->with('measures', $measures); } diff --git a/app/Http/Controllers/DocumentController.php b/app/Http/Controllers/DocumentController.php index 7d9adc04..c200c90c 100644 --- a/app/Http/Controllers/DocumentController.php +++ b/app/Http/Controllers/DocumentController.php @@ -263,19 +263,16 @@ public function index() public function check() { - // Only for administrator abort_if( - (Auth::User()->role !== 1), + ! Auth::User()->isAdmin(), Response::HTTP_FORBIDDEN, '403 Forbidden' ); - // Get all documents with computed metadata - $documents = Document::all()->map(function ($doc) { + $documents = Document::all()->map(function (Document $doc): Document { $filePath = storage_path('docs/' . $doc->id); $fileExists = file_exists($filePath); - // Add computed attributes to document object $doc->file_exists = $fileExists; $doc->link_count = 0; $doc->hash_valid = false; @@ -283,16 +280,16 @@ public function check() if ($fileExists) { $stats = stat($filePath); if ($stats !== false) { - $doc->link_count = $stats['nlink'] ?? 0; - } + $doc->link_count = $stats['nlink']; + } $computedHash = hash_file('sha256', $filePath); - $doc->hash_valid = $computedHash !== false && $doc->hash !== null && hash_equals($doc->hash, $computedHash); + $doc->hash_valid = $computedHash !== false + && hash_equals($doc->hash, $computedHash); } return $doc; }); - // Show view with pre-computed metadata return view('/documents/check') ->with('documents', $documents); } diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 43b06bd9..183359ed 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Models\Risk; use Carbon\Carbon; use Illuminate\Http\Request; use Illuminate\Http\Response; @@ -38,6 +39,7 @@ public function index(Request $request) $planedControlsThisMonthCount = $this->getPlanedControlsThisMonthCount(); $lateControlsCount = $this->getLateControlsCount(); $actionPlansCount = $this->getActionPlansCount(); + $risksCount = $this->getRisksCount(); $activeControls = $this->getActiveControls(); $controlsTodo = $this->getControlsTodo(); @@ -57,6 +59,7 @@ public function index(Request $request) 'active_measures_count' => $activeMeasuresCount, 'controls_made_count' => $controlsMadeCount, 'controls_never_made' => $controlsNeverMade, + 'risks_count' => $risksCount, 'active_controls' => $activeControls, 'controls_todo' => $controlsTodo, 'action_plans_count' => $actionPlansCount, @@ -446,4 +449,15 @@ private function getExpandedControls() return $expanded; }); } + + private function getRisksCount() { + $query = Risk::query(); + + if (Auth::user()->isAuditee()) { + $query->ownedBy(Auth::user()->id); + } + + return $query->count(); + } + } diff --git a/app/Http/Controllers/RiskController.php b/app/Http/Controllers/RiskController.php new file mode 100644 index 00000000..ead11b36 --- /dev/null +++ b/app/Http/Controllers/RiskController.php @@ -0,0 +1,320 @@ +orderByDesc('updated_at'); + + if ($user->role === 3) { + $query->ownedBy($user->id); + } + + if ($request->filled('status') && array_key_exists($request->status, Risk::STATUS_LABELS)) { + $query->byStatus($request->status); + } + + if ($request->filled('owner') && $user->role !== 3) { + $query->ownedBy((int) $request->owner); + } + + if ($request->boolean('overdue')) { + $query->overdue(); + } + + if ($request->filled('threshold') && is_numeric($request->threshold)) { + $thresholds = $this->scoringService->config()->risk_thresholds; + $idx = (int) $request->threshold; + if (isset($thresholds[$idx])) { + $min = $idx > 0 ? ($thresholds[$idx - 1]['max'] + 1) : 1; + $max = $thresholds[$idx]['max']; + $query->whereRaw('probability * impact >= ?', [$min]); + if ($max) { + $query->whereRaw('probability * impact <= ?', [$max]); + } + } + } + + + if ($request->filled('search')) { + $search = '%' . $request->search . '%'; + $query->where(function ($q) use ($search) { + $q->where('name', 'like', $search) + ->orWhere('description', 'like', $search); + }); + } + + // Persister les filtres en session (comme Deming le fait pour bob/index) + session([ + 'risk_status' => $request->input('status'), + 'risk_owner' => $request->input('owner'), + 'risk_overdue' => $request->input('overdue', '0'), + ]); + + $risks = $query->paginate(50)->withQueryString(); + $owners = User::query()->orderBy('name')->get(); + $filters = $request->only(['status', 'owner', 'overdue', 'search']); + $scoringConfig = $this->scoringService->config(); + + return view('risks.index', compact('risks', 'owners', 'filters', 'scoringConfig')); + } + + // ========================================================================= + // CREATE / STORE + // ========================================================================= + + public function create(): View + { + $users = User::query()->orderBy('name')->get(); + $measures = Measure::query()->orderBy('name')->get(); + $actions = Action::query()->orderBy('name')->get(); + $statuses = Risk::STATUS_LABELS; + $scoringConfig = $this->scoringService->config(); + + return view('risks.create', + compact('users', 'measures', 'actions', 'statuses', 'scoringConfig')); + } + + public function store(Request $request): RedirectResponse + { + $validated = $this->validateRisk($request); + + $risk = Risk::query()->create($validated); + + if (empty($validated['next_review_at'])) { + $risk->next_review_at = now()->addMonths((int) $risk->review_frequency); + $risk->saveQuietly(); + } + + $this->syncRelations($risk, $request); + $this->warnBusinessRules($risk); + + return redirect('/risk/show/' . $risk->id) + ->with('success', __('Risque créé avec succès.')); + } + + // ========================================================================= + // SHOW + // ========================================================================= + + public function show(int $id): View + { + $risk = Risk::query()->findOrFail($id); + $this->authorizeView($risk); + + $risk->load(['owner', 'measures', 'actions']); + + $scoringConfig = $this->scoringService->config(); + + return view('risks.show', compact('risk', 'scoringConfig')); + } + + // ========================================================================= + // EDIT / UPDATE (route POST /risk/save, comme /bob/save) + // ========================================================================= + + public function edit(int $id): View + { + $risk = Risk::query()->findOrFail($id); + $users = User::query()->orderBy('name')->get(); + $measures = Measure::query()->orderBy('name')->get(); + $actions = Action::query()->orderBy('name')->get(); + $statuses = Risk::STATUS_LABELS; + $scoringConfig = $this->scoringService->config(); + + $risk->load(['measures', 'actions']); + + return view('risks.edit', + compact('risk', 'users', 'measures', 'actions', 'statuses', 'scoringConfig')); + } + + public function update(Request $request): RedirectResponse + { + $risk = Risk::query()->findOrFail($request->input('id')); + $validated = $this->validateRisk($request); + + $frequencyChanged = (int) $validated['review_frequency'] !== $risk->review_frequency; + if ($frequencyChanged && empty($validated['next_review_at'])) { + $validated['next_review_at'] = now()->addMonths((int) $validated['review_frequency']); + } + + $risk->update($validated); + $risk->invalidateScoringCache(); + + $this->syncRelations($risk, $request); + $this->warnBusinessRules($risk); + + return redirect('/risk/show/' . $risk->id) + ->with('success', __('Risque mis à jour.')); + } + + // ========================================================================= + // DELETE (route GET /risk/delete/{id}, comme /bob/delete/{id}) + // ========================================================================= + + public function destroy(int $id): RedirectResponse + { + if (Auth::user()->role !== 1) { + abort(403); + } + + Risk::query()->findOrFail($id)->delete(); + + return redirect('/risk/index') + ->with('success', __('Risque supprimé.')); + } + + // ========================================================================= + // MATRIX + // ========================================================================= + + public function matrix(): View + { + $risks = Risk::with('owner')->get(); + + $matrix = $this->scoringService->buildMatrix($risks); + $xAxis = $this->scoringService->matrixXAxis(); + $yAxis = $this->scoringService->matrixYAxis(); + + $scoringConfig = $this->scoringService->config(); + $thresholds = $scoringConfig->risk_thresholds; + + $stats = [ + 'critical' => $risks->filter(fn($r) => $r->risk_level === 'critical')->count(), + 'high' => $risks->filter(fn($r) => $r->risk_level === 'high')->count(), + 'medium' => $risks->filter(fn($r) => $r->risk_level === 'medium')->count(), + 'low' => $risks->filter(fn($r) => $r->risk_level === 'low')->count(), + 'total' => $risks->count(), + 'overdue' => $risks->filter(fn($r) => $r->is_overdue)->count(), + 'by_status' => $risks->groupBy('status')->map->count(), + + 'by_level' => collect($thresholds) + ->mapWithKeys(fn($t, $i) => [ + $i => $risks->filter(function ($r) use ($thresholds, $i) { + $score = $r->probability * $r->impact; + $min = $i > 0 ? ($thresholds[$i - 1]['max'] + 1) : 1; + $max = $thresholds[$i]['max']; + return $max ? ($score >= $min && $score <= $max) : $score >= $min; + })->count(), + ]), + ]; + + $scoringConfig = $this->scoringService->config(); + + return view('risks.matrix', compact('matrix', 'stats', 'scoringConfig', 'xAxis', 'yAxis')); + } + + // ========================================================================= + // Privé + // ========================================================================= + + private function validateRisk(Request $request): array + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'owner_id' => ['nullable', 'exists:users,id'], + 'probability' => ['required', 'integer', 'min:1'], + 'probability_comment' => ['nullable', 'string'], + 'impact' => ['required', 'integer', 'min:1'], + 'impact_comment' => ['nullable', 'string'], + 'exposure' => ['nullable', 'integer', 'min:0'], + 'vulnerability' => ['nullable', 'integer', 'min:1'], + 'status' => ['required', 'in:' . implode(',', array_keys(Risk::STATUS_LABELS))], + 'status_comment' => ['nullable', 'string'], + 'review_frequency' => ['required', 'integer', 'min:1', 'max:60'], + 'next_review_at' => ['nullable', 'date'], + 'measure_ids' => ['nullable', 'array'], + 'measure_ids.*' => ['exists:measures,id'], + 'action_ids' => ['nullable', 'array'], + 'action_ids.*' => ['exists:actions,id'], + ]); + + // Laravel retourne les champs numériques sous forme de string depuis le POST. + // Carbon::addMonths() et les comparaisons requièrent des int. + foreach (['probability', 'impact', 'review_frequency'] as $field) { + if (isset($data[$field])) { + $data[$field] = (int) $data[$field]; + } + } + foreach (['exposure', 'vulnerability', 'owner_id'] as $field) { + if (isset($data[$field])) { + $data[$field] = (int) $data[$field]; + } + } + + return $data; + } + + private function syncRelations(Risk $risk, Request $request): void + { + $risk->measures()->sync($request->input('measure_ids', [])); + $risk->actions()->sync($request->input('action_ids', [])); + } + + private function warnBusinessRules(Risk $risk): void + { + if ($risk->requiresMeasures() && $risk->measures()->count() === 0) { + session()->flash('warning', __('Un risque "Mitigé" doit avoir au moins un contrôle lié.')); + } + if ($risk->requiresActions() && $risk->actions()->count() === 0) { + session()->flash('warning', __('Un risque "Non accepté" doit avoir au moins un plan d\'action lié.')); + } + } + + private function authorizeView(Risk $risk): void + { + if (Auth::user()->role === 3 && $risk->owner_id !== Auth::id()) { + abort(403); + } + } + + public function export() + { + // For administrators and users only + abort_if( + !Auth::User()->isAdmin() && !Auth::User()->isUser(), + Response::HTTP_FORBIDDEN, + '403 Forbidden' + ); + + return Excel::download( + new RiskExport(), + trans('cruds.risk.plural') . + '-' . + now()->format('Y-m-d Hi') . + '.xlsx' + ); + } + +} \ No newline at end of file diff --git a/app/Http/Controllers/RiskScoringConfigController.php b/app/Http/Controllers/RiskScoringConfigController.php new file mode 100644 index 00000000..b9441a43 --- /dev/null +++ b/app/Http/Controllers/RiskScoringConfigController.php @@ -0,0 +1,211 @@ +role !== 1) { + abort(403); + } + } + + public function index(): View + { + $this->checkAdmin(); + + $configs = RiskScoringConfig::query()->orderByDesc('is_active')->orderBy('name')->get(); + $formulas = $this->scoringService->availableFormulas(); + + return view('risks.scoring.index', compact('configs', 'formulas')); + } + + public function create(): View + { + $this->checkAdmin(); + + $formulas = $this->scoringService->availableFormulas(); + $config = new RiskScoringConfig([ + 'formula' => 'probability_x_impact', + 'probability_levels' => [ + ['value' => 1, 'label' => 'Rare', 'description' => ''], + ['value' => 2, 'label' => 'Peu probable', 'description' => ''], + ['value' => 3, 'label' => 'Possible', 'description' => ''], + ['value' => 4, 'label' => 'Probable', 'description' => ''], + ['value' => 5, 'label' => 'Très probable', 'description' => ''], + ], + 'impact_levels' => [ + ['value' => 1, 'label' => 'Négligeable', 'description' => ''], + ['value' => 2, 'label' => 'Faible', 'description' => ''], + ['value' => 3, 'label' => 'Modéré', 'description' => ''], + ['value' => 4, 'label' => 'Élevé', 'description' => ''], + ['value' => 5, 'label' => 'Critique', 'description' => ''], + ], + 'risk_thresholds' => [ + ['level' => 'low', 'label' => 'Faible', 'max' => 4, 'color' => '#27ae60'], + ['level' => 'medium', 'label' => 'Moyen', 'max' => 9, 'color' => '#f39c12'], + ['level' => 'high', 'label' => 'Élevé', 'max' => 16, 'color' => '#e74c3c'], + ['level' => 'critical', 'label' => 'Critique', 'max' => null, 'color' => '#c0392b'], + ], + ]); + + // Variables PHP nécessaires dans la vue form + $probLevels = $config->probability_levels ?? []; + $impLevels = $config->impact_levels ?? []; + $expLevels = $config->exposure_levels ?? []; + $vulnLevels = $config->vulnerability_levels ?? []; + $thresholds = $config->risk_thresholds ?? []; + + return view('risks.scoring.form', compact('config', 'formulas', 'probLevels', 'impLevels', 'expLevels', 'vulnLevels', 'thresholds')); + } + + public function store(Request $request): RedirectResponse + { + $this->checkAdmin(); + + $validated = $this->validateConfig($request); + $validated['is_active'] = false; + + RiskScoringConfig::create($validated); + + return redirect('/risk/scoring') + ->with('success', __('Configuration créée. Activez-la pour l\'appliquer.')); + } + + public function edit(int $id): View + { + $this->checkAdmin(); + + $config = RiskScoringConfig::findOrFail($id); + $formulas = $this->scoringService->availableFormulas(); + + $probLevels = $config->probability_levels ?? []; + $impLevels = $config->impact_levels ?? []; + $expLevels = $config->exposure_levels ?? []; + $vulnLevels = $config->vulnerability_levels ?? []; + $thresholds = $config->risk_thresholds ?? []; + + return view('risks.scoring.form', compact('config', 'formulas', 'probLevels', 'impLevels', 'expLevels', 'vulnLevels', 'thresholds')); + } + + public function update(Request $request, int $id): RedirectResponse + { + $this->checkAdmin(); + + $config = RiskScoringConfig::findOrFail($id); + $validated = $this->validateConfig($request); + $config->update($validated); + + if ($config->is_active) { + RiskScoringConfig::clearCache(); + } + + return redirect('/risk/scoring') + ->with('success', __('Configuration mise à jour.')); + } + + public function activate(int $id): RedirectResponse + { + $this->checkAdmin(); + + $config = RiskScoringConfig::findOrFail($id); + $config->activate(); + + return redirect('/risk/scoring') + ->with('messages', [__('Configuration "' . $config->name . '" activée.')]); + } + + public function destroy(int $id): RedirectResponse + { + $this->checkAdmin(); + + $config = RiskScoringConfig::findOrFail($id); + + if ($config->is_active) { + return back()->with('errors', [__('Impossible de supprimer la configuration active.')]); + } + + $config->delete(); + + return redirect('/risk/scoring') + ->with('messages', [__('Configuration supprimée.')]); + } + + private function validateConfig(Request $request): array + { + $data = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'formula' => ['required', 'in:' . implode(',', array_keys(RiskScoringService::FORMULAS))], + + 'probability_levels' => ['nullable', 'array'], + 'probability_levels.*.value' => ['required_with:probability_levels', 'integer'], + 'probability_levels.*.label' => ['required_with:probability_levels', 'string', 'max:100'], + 'probability_levels.*.description'=> ['nullable', 'string', 'max:255'], + + 'impact_levels' => ['required', 'array', 'min:2'], + 'impact_levels.*.value' => ['required', 'integer', 'min:1'], + 'impact_levels.*.label' => ['required', 'string', 'max:100'], + 'impact_levels.*.description'=> ['nullable', 'string', 'max:255'], + + 'exposure_levels' => ['nullable', 'array'], + 'exposure_levels.*.value' => ['nullable', 'integer', 'min:0'], + 'exposure_levels.*.label' => ['nullable', 'string', 'max:100'], + 'exposure_levels.*.description'=> ['nullable', 'string', 'max:255'], + + 'vulnerability_levels' => ['nullable', 'array'], + 'vulnerability_levels.*.value' => ['nullable', 'integer', 'min:1'], + 'vulnerability_levels.*.label' => ['nullable', 'string', 'max:100'], + 'vulnerability_levels.*.description'=> ['nullable', 'string', 'max:255'], + + 'risk_thresholds' => ['required', 'array', 'min:2'], + 'risk_thresholds.*.level'=> ['required', 'string'], + 'risk_thresholds.*.label'=> ['required', 'string', 'max:100'], + 'risk_thresholds.*.max' => ['nullable', 'integer', 'min:1'], + 'risk_thresholds.*.color'=> ['required', 'regex:/^#[0-9a-fA-F]{6}$/'], + ]); + + $needsExposure = RiskScoringService::FORMULAS[$data['formula']]['requires_exposure'] ?? false; + if (! $needsExposure) { + $data['probability_levels'] = $data['probability_levels'] ?? []; + $data['exposure_levels'] = null; + $data['vulnerability_levels'] = null; + } + + // Le dernier seuil n'a pas de borne supérieure + $last = count($data['risk_thresholds']) - 1; + $data['risk_thresholds'][$last]['max'] = null; + + return $data; + } + +// ------------------------------------------------------------------------- +// Helpers couleurs : migration legacy (noms de classes MetroUI) → hex +// ------------------------------------------------------------------------- + +/* + private const COLOR_MAP = [ + 'success' => '#27ae60', + 'warning' => '#f39c12', + 'danger' => '#e74c3c', + 'alert' => '#c0392b', + 'info' => '#2980b9', + 'secondary' => '#7f8c8d', + ]; +*/ +} \ No newline at end of file diff --git a/app/Models/Control.php b/app/Models/Control.php index c6dd4031..e220a42f 100644 --- a/app/Models/Control.php +++ b/app/Models/Control.php @@ -6,9 +6,11 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\File; +use Mercator\Core\Models\Operation; class Control extends Model { @@ -53,21 +55,25 @@ class Control extends Model // 1 - Proposed by auditee => relisation date not null // 2 - Done => relisation date not null + /** @return BelongsToMany */ public function measures(): BelongsToMany { return $this->belongsToMany(Measure::class)->orderBy('clause'); } + /** @return HasMany */ public function actions(): HasMany { return $this->hasMany(Action::class); } + /** @return HasMany */ public function documents(): HasMany { return $this->hasMany(Document::class); } + /** @return BelongsToMany */ public function users(): BelongsToMany { if ($this->users === null) { @@ -76,6 +82,7 @@ public function users(): BelongsToMany return $this->users; } + /** @return BelongsToMany */ public function groups() { if ($this->groups === null) { @@ -118,7 +125,7 @@ public function canValidate(): bool return false; } - public function clauses(int $id) + public function clauses(int $id) : Collection { return DB::table('measures') ->select('measure_id', 'clause') @@ -126,7 +133,7 @@ public function clauses(int $id) ->get(); } - public static function cleanup(string $startDate, bool $dryRun) + public static function cleanup(string $startDate, bool $dryRun) : array { // Initialise counters $documentCount = 0; diff --git a/app/Models/Document.php b/app/Models/Document.php index 198aa508..3051cfb7 100644 --- a/app/Models/Document.php +++ b/app/Models/Document.php @@ -4,6 +4,11 @@ use Illuminate\Database\Eloquent\Model; +/** + * @property bool $file_exists + * @property int $link_count + * @property bool $hash_valid + */ class Document extends Model { diff --git a/app/Models/Risk.php b/app/Models/Risk.php new file mode 100644 index 00000000..7a5bc865 --- /dev/null +++ b/app/Models/Risk.php @@ -0,0 +1,234 @@ + 'Non évalué', + self::STATUS_NOT_ACCEPTED => 'Non accepté', + self::STATUS_TEMPORARILY_ACCEPTED => 'Accepté temporairement', + self::STATUS_ACCEPTED => 'Accepté', + self::STATUS_MITIGATED => 'Mitigé', + self::STATUS_TRANSFERRED => 'Transféré', + self::STATUS_AVOIDED => 'Évité', + ]; + + const STATUS_COLORS = [ + self::STATUS_NOT_EVALUATED => 'secondary', + self::STATUS_NOT_ACCEPTED => 'danger', + self::STATUS_TEMPORARILY_ACCEPTED => 'warning', + self::STATUS_ACCEPTED => 'success', + self::STATUS_MITIGATED => 'info', + self::STATUS_TRANSFERRED => 'light', + self::STATUS_AVOIDED => 'dark', + ]; + + // ------------------------------------------------------------------------- + // Fillable / Casts + // ------------------------------------------------------------------------- + + protected $fillable = [ + 'name', 'description', 'owner_id', + 'probability', 'probability_comment', + 'impact', 'impact_comment', + 'exposure', 'vulnerability', + 'status', 'status_comment', + 'review_frequency', 'next_review_at', + ]; + + protected $casts = [ + 'next_review_at' => 'date', + 'probability' => 'integer', + 'impact' => 'integer', + 'exposure' => 'integer', + 'vulnerability' => 'integer', + ]; + + // ------------------------------------------------------------------------- + // Relations + // ------------------------------------------------------------------------- + + public function owner(): BelongsTo + { + return $this->belongsTo(User::class, 'owner_id'); + } + + public function measures(): BelongsToMany + { + return $this->belongsToMany(Measure::class, 'measure_risk'); + } + + public function actions(): BelongsToMany + { + return $this->belongsToMany(Action::class, 'action_risk'); + } + + // ------------------------------------------------------------------------- + // Accesseurs calculés — délèguent au RiskScoringService + // + // Le service est résolu via le conteneur Laravel (singleton enregistré + // dans AppServiceProvider). Le résultat est mis en cache sur l'instance + // pour éviter des appels répétés dans une même requête. + // ------------------------------------------------------------------------- + + /** @var array|null Cache du résultat de scoring pour cet objet */ + private ?array $scoringResult = null; + + private function scoringResult(): array + { + if ($this->scoringResult === null) { + $this->scoringResult = app(RiskScoringService::class)->score($this); + } + + return $this->scoringResult; + } + + /** Invalide le cache de scoring (utile après modification des attributs) */ + public function invalidateScoringCache(): void + { + $this->scoringResult = null; + } + + /** Score brut calculé selon la formule active */ + public function getRiskScoreAttribute(): int + { + return $this->scoringResult()['score']; + } + + /** + * Vraisemblance intermédiaire (Exposition + Vulnérabilité). + * Retourne null si la formule active ne l'utilise pas. + */ + public function getRiskLikelihoodAttribute(): ?int + { + return $this->scoringResult()['likelihood']; + } + + /** Niveau de risque : 'low'|'medium'|'high'|'critical' */ + public function getRiskLevelAttribute(): string + { + return $this->scoringResult()['level']; + } + + /** Label localisé du niveau (ex. "Élevé") */ + public function getRiskLevelLabelAttribute(): string + { + return $this->scoringResult()['label']; + } + + /** Classe CSS MetroUI pour le badge du niveau (ex. "danger") */ + public function getRiskLevelColorAttribute(): string + { + return $this->scoringResult()['color']; + } + + /** Indique si la prochaine revue est dépassée */ + public function getIsOverdueAttribute(): bool + { + return $this->next_review_at !== null + && $this->next_review_at->isPast(); + } + + // ------------------------------------------------------------------------- + // Helpers métier + // ------------------------------------------------------------------------- + + public function requiresMeasures(): bool + { + return $this->status === self::STATUS_MITIGATED; + } + + public function requiresActions(): bool + { + return $this->status === self::STATUS_NOT_ACCEPTED; + } + + // ------------------------------------------------------------------------- + // Scopes + // ------------------------------------------------------------------------- + + public function scopeByStatus($query, string $status) + { + return $query->where('status', $status); + } + + public function scopeOverdue($query) + { + return $query->whereNotNull('next_review_at') + ->where('next_review_at', '<', now()); + } + + public function scopeOwnedBy($query, int $userId) + { + return $query->where('owner_id', $userId); + } + + /** + * Calcule le score brut selon la configuration de scoring active. + * + * @param RiskScoringConfig $config + * @return int + */ + public function computedScore(RiskScoringConfig $config): int + { + if ($config->usesLikelihood()) { + // Modèle 3 facteurs : Likelihood × Vulnerability × Impact + return $this->risk_likelihood * $this->vulnerability * $this->impact; + } + + // Modèle classique probabilité × impact + return $this->probability * $this->impact; + } + +} \ No newline at end of file diff --git a/app/Models/RiskScoringConfig.php b/app/Models/RiskScoringConfig.php new file mode 100644 index 00000000..8ab27b21 --- /dev/null +++ b/app/Models/RiskScoringConfig.php @@ -0,0 +1,179 @@ + 'boolean', + 'probability_levels' => 'array', + 'impact_levels' => 'array', + 'exposure_levels' => 'array', + 'vulnerability_levels' => 'array', + 'risk_thresholds' => 'array', + ]; + + // ------------------------------------------------------------------------- + // Récupération de la config active (avec cache requête) + // ------------------------------------------------------------------------- + + private static ?self $activeInstance = null; + + /** + * Retourne la configuration de scoring active. + * Met en cache l'instance pour la durée de la requête. + */ + public static function active(): self + { + if (self::$activeInstance === null) { + self::$activeInstance = static::where('is_active', true)->firstOrFail(); + } + + return self::$activeInstance; + } + + /** Invalide le cache (à appeler après activation d'une nouvelle config) */ + public static function clearCache(): void + { + self::$activeInstance = null; + } + + // ------------------------------------------------------------------------- + // Activation + // ------------------------------------------------------------------------- + + /** + * Active cette configuration et désactive toutes les autres. + * Opération atomique via transaction. + */ + public function activate(): void + { + \DB::transaction(function () { + self::query()->update(['is_active' => false]); + $this->update(['is_active' => true]); + self::clearCache(); + }); + } + + // ------------------------------------------------------------------------- + // Helpers sur les niveaux + // ------------------------------------------------------------------------- + + /** + * Retourne le label d'un niveau pour un champ donné. + * + * @param string $field 'probability'|'impact'|'exposure'|'vulnerability' + * @param int $value Valeur numérique du niveau + */ + public function levelLabel(string $field, int $value): string + { + $levels = $this->{$field . '_levels'} ?? []; + + foreach ($levels as $level) { + if ((int) $level['value'] === $value) { + return $level['label']; + } + } + + return (string) $value; + } + + /** + * Retourne toutes les valeurs possibles pour un champ de niveau. + * + * @return int[] + */ + public function levelValues(string $field): array + { + return array_column($this->{$field . '_levels'} ?? [], 'value'); + } + + /** + * Indique si cette formule utilise exposition + vulnérabilité + * (au lieu de probabilité directe). + */ + public function usesLikelihood(): bool + { + return $this->formula === 'likelihood_x_impact'; + } + + // ------------------------------------------------------------------------- + // Helpers sur les seuils + // ------------------------------------------------------------------------- + + /** + * Retourne le seuil correspondant à un score donné. + * + * @return array{level: string, label: string, color: string} + */ + public function thresholdFor(int $score): array + { + foreach ($this->risk_thresholds as $threshold) { + if ($threshold['max'] === null || $score <= $threshold['max']) { + return $threshold; + } + } + + // Fallback sur le dernier seuil défini + return end($this->risk_thresholds); + } + + /** + * Score maximum théorique pour cette configuration. + * Utile pour normaliser l'affichage de la matrice. + */ + public function maxScore(): int + { + return match ($this->formula) { + 'likelihood_x_impact' => $this->maxLevelValue('exposure') + + $this->maxLevelValue('vulnerability') + + $this->maxLevelValue('impact'), + 'additive' => $this->maxLevelValue('probability') + $this->maxLevelValue('impact'), + 'max_pi' => max($this->maxLevelValue('probability'), $this->maxLevelValue('impact')), + default => $this->maxLevelValue('probability') * $this->maxLevelValue('impact'), + }; + } + + private function maxLevelValue(string $field): int + { + $values = $this->levelValues($field); + return $values ? max($values) : 0; + } + + public function thresholdIndexFor(int $score): int + { + foreach ($this->risk_thresholds as $i => $threshold) { + if ($threshold['max'] === null || $score <= $threshold['max']) { + return $i; + } + } + return count($this->risk_thresholds) - 1; + } + +} \ No newline at end of file diff --git a/app/Models/User.php b/app/Models/User.php index 5c753885..23831ec7 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,9 +7,10 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Passport\Contracts\OAuthenticatable; use Laravel\Passport\HasApiTokens; -class User extends Authenticatable +class User extends Authenticatable implements OAuthenticatable { use HasApiTokens, HasFactory, Notifiable, Auditable; diff --git a/app/Services/RiskScoringService.php b/app/Services/RiskScoringService.php new file mode 100644 index 00000000..45c70952 --- /dev/null +++ b/app/Services/RiskScoringService.php @@ -0,0 +1,246 @@ +score($risk); + * // => ['score' => 12, 'level' => 'high', 'label' => 'Élevé', 'color' => 'danger'] + * + * Enregistrement dans AppServiceProvider : + * $this->app->singleton(RiskScoringService::class); + */ +class RiskScoringService +{ + // ------------------------------------------------------------------------- + // Catalogue des formules disponibles + // ------------------------------------------------------------------------- + + /** + * Liste des formules proposées dans l'interface de configuration. + * + * Clé = valeur stockée en base. + * Valeur = [label affiché, description, champs requis sur le risque] + */ + public const FORMULAS = [ + 'probability_x_impact' => [ + 'label' => 'Probabilité × Impact', + 'description' => 'Formule classique ISO 27005 / ISO 27001. Score = P × I. Matrice 5×5 standard.', + 'requires' => ['probability', 'impact'], + 'requires_exposure' => false, + ], + 'likelihood_x_impact' => [ + 'label' => 'Vraisemblance × Impact (BSI 200-3)', + 'description' => 'Méthode ISACA / BSI IT-Grundschutz. Vraisemblance = Exposition + Vulnérabilité. Score = V × I.', + 'requires' => ['exposure', 'vulnerability', 'impact'], + 'requires_exposure' => true, + ], + 'additive' => [ + 'label' => 'Probabilité + Impact', + 'description' => 'Méthode additive simplifiée. Score = P + I. Appropriée pour un premier triage rapide.', + 'requires' => ['probability', 'impact'], + 'requires_exposure' => false, + ], + 'max_pi' => [ + 'label' => 'max(Probabilité, Impact)', + 'description' => 'Approche conservatrice : le score est dominé par la dimension la plus défavorable.', + 'requires' => ['probability', 'impact'], + 'requires_exposure' => false, + ], + ]; + + // ------------------------------------------------------------------------- + // Construction + // ------------------------------------------------------------------------- + + private RiskScoringConfig $config; + + public function __construct() + { + $this->config = RiskScoringConfig::active(); + } + + // ------------------------------------------------------------------------- + // API principale + // ------------------------------------------------------------------------- + + /** + * Calcule le score et le niveau de risque pour un risque donné. + * + * @return array{ + * score: int, + * likelihood: int|null, + * level: string, + * label: string, + * color: string, + * max_score: int, + * } + */ + public function score(Risk $risk): array + { + [$score, $likelihood] = $this->calculate($risk); + $threshold = $this->config->thresholdFor($score); + + return [ + 'score' => $score, + 'likelihood' => $likelihood, // null si formule sans exposition + 'level' => $threshold['level'], + 'label' => $threshold['label'], + 'color' => $threshold['color'], + 'max_score' => $this->config->maxScore(), + ]; + } + + /** + * Expose la configuration active (pour les vues de formulaire). + */ + public function config(): RiskScoringConfig + { + return $this->config; + } + + /** + * Expose le catalogue des formules disponibles. + */ + public function availableFormulas(): array + { + return self::FORMULAS; + } + + /** + * Génère les données pour la matrice de risque. + * + * Retourne une structure [score][statut] => nombre de risques, + * adaptée à la formule active (axes variables). + * + * @param \Illuminate\Support\Collection $risks + * @return array + */ + public function buildMatrix(\Illuminate\Support\Collection $risks): array + { + $matrix = []; + + foreach ($risks as $risk) { + $result = $this->score($risk); + $x = $this->xAxisValue($risk); // axe horizontal (impact) + $y = $this->yAxisValue($risk); // axe vertical (prob. ou vraisemblance) + + $matrix[$y][$x][] = [ + 'id' => $risk->id, + 'name' => $risk->name, + 'score' => $result['score'], + 'level' => $result['level'], + 'color' => $result['color'], + 'status' => $risk->status, + ]; + } + + return $matrix; + } + + /** Labels et valeurs pour l'axe X de la matrice (toujours l'impact) */ + public function matrixXAxis(): array + { + return $this->config->impact_levels ?? []; + } + + /** Labels et valeurs pour l'axe Y de la matrice (prob. ou vraisemblance) */ + public function matrixYAxis(): array + { + if ($this->config->usesLikelihood()) { + // Générer les valeurs de vraisemblance = toutes combinaisons exposition + vulnérabilité + $exposures = array_column($this->config->exposure_levels ?? [], 'value'); + $vulnerabilities = array_column($this->config->vulnerability_levels ?? [], 'value'); + $likelihoods = []; + + foreach ($exposures as $e) { + foreach ($vulnerabilities as $v) { + $likelihoods[$e + $v] = $e + $v; + } + } + ksort($likelihoods); + + return array_map( + fn($l) => ['value' => $l, 'label' => "Vraisemblance $l"], + array_values($likelihoods) + ); + } + + return $this->config->probability_levels ?? []; + } + + // ------------------------------------------------------------------------- + // Calcul interne par formule + // ------------------------------------------------------------------------- + + /** + * @return array{int, int|null} [score, likelihood|null] + */ + private function calculate(Risk $risk): array + { + return match ($this->config->formula) { + 'probability_x_impact' => $this->formulaProbabilityXImpact($risk), + 'likelihood_x_impact' => $this->formulaLikelihoodXImpact($risk), + 'additive' => $this->formulaAdditive($risk), + 'max_pi' => $this->formulaMaxPI($risk), + default => $this->formulaProbabilityXImpact($risk), + }; + } + + /** Score = Probabilité × Impact */ + private function formulaProbabilityXImpact(Risk $risk): array + { + return [$risk->probability * $risk->impact, null]; + } + + /** + * Vraisemblance = Exposition + Vulnérabilité + * Score = Vraisemblance × Impact + */ + private function formulaLikelihoodXImpact(Risk $risk): array + { + $likelihood = ($risk->exposure ?? 0) + ($risk->vulnerability ?? 0); + return [$likelihood * $risk->impact, $likelihood]; + } + + /** Score = Probabilité + Impact */ + private function formulaAdditive(Risk $risk): array + { + return [$risk->probability + $risk->impact, null]; + } + + /** Score = max(Probabilité, Impact) */ + private function formulaMaxPI(Risk $risk): array + { + return [max($risk->probability, $risk->impact), null]; + } + + // ------------------------------------------------------------------------- + // Axes matrice + // ------------------------------------------------------------------------- + + private function xAxisValue(Risk $risk): int + { + return $risk->impact ?? 1; + } + + private function yAxisValue(Risk $risk): int + { + if ($this->config->usesLikelihood()) { + return ($risk->exposure ?? 0) + ($risk->vulnerability ?? 0); + } + + return $risk->probability ?? 1; + } +} \ No newline at end of file diff --git a/composer.lock b/composer.lock index 8045c38c..c0206c51 100644 --- a/composer.lock +++ b/composer.lock @@ -435,24 +435,24 @@ }, { "name": "directorytree/ldaprecord", - "version": "v3.8.5", + "version": "v3.8.6", "source": { "type": "git", "url": "https://github.com/DirectoryTree/LdapRecord.git", - "reference": "00e5f088f8c4028d5f398783cccc2e8119a27a65" + "reference": "a1cab9f078eb4756fc6fad58caea1e3d11cad0ec" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/DirectoryTree/LdapRecord/zipball/00e5f088f8c4028d5f398783cccc2e8119a27a65", - "reference": "00e5f088f8c4028d5f398783cccc2e8119a27a65", + "url": "https://api.github.com/repos/DirectoryTree/LdapRecord/zipball/a1cab9f078eb4756fc6fad58caea1e3d11cad0ec", + "reference": "a1cab9f078eb4756fc6fad58caea1e3d11cad0ec", "shasum": "" }, "require": { "ext-iconv": "*", "ext-json": "*", "ext-ldap": "*", - "illuminate/collections": "^8.0|^9.0|^10.0|^11.0|^12.0", - "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/collections": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", "nesbot/carbon": "*", "php": ">=8.1", "psr/log": "*", @@ -507,27 +507,27 @@ "type": "github" } ], - "time": "2025-10-06T02:22:34+00:00" + "time": "2026-03-19T20:55:30+00:00" }, { "name": "directorytree/ldaprecord-laravel", - "version": "v3.4.2", + "version": "v3.4.3", "source": { "type": "git", "url": "https://github.com/DirectoryTree/LdapRecord-Laravel.git", - "reference": "28c5a7aa42aa3fa631f9c0f0c8236fd19bc7b00c" + "reference": "86925b558655e1595aabccbb8c530eb747e87ef5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/DirectoryTree/LdapRecord-Laravel/zipball/28c5a7aa42aa3fa631f9c0f0c8236fd19bc7b00c", - "reference": "28c5a7aa42aa3fa631f9c0f0c8236fd19bc7b00c", + "url": "https://api.github.com/repos/DirectoryTree/LdapRecord-Laravel/zipball/86925b558655e1595aabccbb8c530eb747e87ef5", + "reference": "86925b558655e1595aabccbb8c530eb747e87ef5", "shasum": "" }, "require": { "directorytree/ldaprecord": "^v3.3", "ext-json": "*", "ext-ldap": "*", - "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", "php": ">=8.1", "ramsey/uuid": "*" }, @@ -535,7 +535,7 @@ "laravel/pint": "^1.9", "laravel/sanctum": "*", "mockery/mockery": "^1.0", - "orchestra/testbench": "^6.0|^7.0|^8.0|^9.0|^10.0", + "orchestra/testbench": "^6.0|^7.0|^8.0|^9.0|^10.0|^11.0", "phpunit/phpunit": "^8.0|^9.0|^10.0|^11.0", "spatie/ray": "^1.28" }, @@ -566,7 +566,7 @@ ], "support": { "issues": "https://github.com/DirectoryTree/LdapRecord-Laravel/issues", - "source": "https://github.com/DirectoryTree/LdapRecord-Laravel/tree/v3.4.2" + "source": "https://github.com/DirectoryTree/LdapRecord-Laravel/tree/v3.4.3" }, "funding": [ { @@ -574,7 +574,7 @@ "type": "github" } ], - "time": "2025-06-13T15:46:25+00:00" + "time": "2026-03-19T21:11:17+00:00" }, { "name": "doctrine/inflector", @@ -993,16 +993,16 @@ }, { "name": "firebase/php-jwt", - "version": "v7.0.3", + "version": "v7.0.5", "source": { "type": "git", "url": "https://github.com/firebase/php-jwt.git", - "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e" + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", - "reference": "28aa0694bcfdfa5e2959c394d5a1ee7a5083629e", + "url": "https://api.github.com/repos/firebase/php-jwt/zipball/47ad26bab5e7c70ae8a6f08ed25ff83631121380", + "reference": "47ad26bab5e7c70ae8a6f08ed25ff83631121380", "shasum": "" }, "require": { @@ -1010,6 +1010,7 @@ }, "require-dev": { "guzzlehttp/guzzle": "^7.4", + "phpfastcache/phpfastcache": "^9.2", "phpspec/prophecy-phpunit": "^2.0", "phpunit/phpunit": "^9.5", "psr/cache": "^2.0||^3.0", @@ -1050,9 +1051,9 @@ ], "support": { "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v7.0.3" + "source": "https://github.com/firebase/php-jwt/tree/v7.0.5" }, - "time": "2026-02-25T22:16:40+00:00" + "time": "2026-04-01T20:38:03+00:00" }, { "name": "fruitcake/php-cors", @@ -1398,16 +1399,16 @@ }, { "name": "guzzlehttp/psr7", - "version": "2.8.0", + "version": "2.9.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "21dc724a0583619cd1652f673303492272778051" + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051", - "reference": "21dc724a0583619cd1652f673303492272778051", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", "shasum": "" }, "require": { @@ -1423,6 +1424,7 @@ "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", "http-interop/http-factory-tests": "0.9.0", + "jshttp/mime-db": "1.54.0.1", "phpunit/phpunit": "^8.5.44 || ^9.6.25" }, "suggest": { @@ -1494,7 +1496,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.8.0" + "source": "https://github.com/guzzle/psr7/tree/2.9.0" }, "funding": [ { @@ -1510,7 +1512,7 @@ "type": "tidelift" } ], - "time": "2025-08-23T21:21:41+00:00" + "time": "2026-03-10T16:41:02+00:00" }, { "name": "guzzlehttp/uri-template", @@ -1600,16 +1602,16 @@ }, { "name": "laravel/framework", - "version": "v11.48.0", + "version": "v11.51.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "5b23ab29087dbcb13077e5c049c431ec4b82f236" + "reference": "c8f9a04594b7044a189a3194cfb3594251eb74e5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/5b23ab29087dbcb13077e5c049c431ec4b82f236", - "reference": "5b23ab29087dbcb13077e5c049c431ec4b82f236", + "url": "https://api.github.com/repos/laravel/framework/zipball/c8f9a04594b7044a189a3194cfb3594251eb74e5", + "reference": "c8f9a04594b7044a189a3194cfb3594251eb74e5", "shasum": "" }, "require": { @@ -1717,10 +1719,10 @@ "league/flysystem-read-only": "^3.25.1", "league/flysystem-sftp-v3": "^3.25.1", "mockery/mockery": "^1.6.10", - "orchestra/testbench-core": "^9.16.1", + "orchestra/testbench-core": "^9.18.0", "pda/pheanstalk": "^5.0.6", "php-http/discovery": "^1.15", - "phpstan/phpstan": "^2.0", + "phpstan/phpstan": "2.1.41", "phpunit/phpunit": "^10.5.35|^11.3.6|^12.0.1", "predis/predis": "^2.3", "resend/resend-php": "^0.10.0", @@ -1811,20 +1813,20 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-01-20T15:26:20+00:00" + "time": "2026-03-26T14:54:53+00:00" }, { "name": "laravel/passport", - "version": "v13.5.0", + "version": "v13.7.3", "source": { "type": "git", "url": "https://github.com/laravel/passport.git", - "reference": "d5bff1040c764da679d96edbed1705b542b33c3d" + "reference": "f4f85e3122c7c6ef375f8bd5395bc7c88801fcc5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/passport/zipball/d5bff1040c764da679d96edbed1705b542b33c3d", - "reference": "d5bff1040c764da679d96edbed1705b542b33c3d", + "url": "https://api.github.com/repos/laravel/passport/zipball/f4f85e3122c7c6ef375f8bd5395bc7c88801fcc5", + "reference": "f4f85e3122c7c6ef375f8bd5395bc7c88801fcc5", "shasum": "" }, "require": { @@ -1886,20 +1888,20 @@ "issues": "https://github.com/laravel/passport/issues", "source": "https://github.com/laravel/passport" }, - "time": "2026-02-23T15:45:16+00:00" + "time": "2026-04-07T13:08:13+00:00" }, { "name": "laravel/prompts", - "version": "v0.3.13", + "version": "v0.3.16", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d" + "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/ed8c466571b37e977532fb2fd3c272c784d7050d", - "reference": "ed8c466571b37e977532fb2fd3c272c784d7050d", + "url": "https://api.github.com/repos/laravel/prompts/zipball/11e7d5f93803a2190b00e145142cb00a33d17ad2", + "reference": "11e7d5f93803a2190b00e145142cb00a33d17ad2", "shasum": "" }, "require": { @@ -1943,9 +1945,9 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.13" + "source": "https://github.com/laravel/prompts/tree/v0.3.16" }, - "time": "2026-02-06T12:17:10+00:00" + "time": "2026-03-23T14:35:33+00:00" }, { "name": "laravel/sanctum", @@ -2012,16 +2014,16 @@ }, { "name": "laravel/serializable-closure", - "version": "v2.0.10", + "version": "v2.0.11", "source": { "type": "git", "url": "https://github.com/laravel/serializable-closure.git", - "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669" + "reference": "d1af40ac4a6ccc12bd062a7184f63c9995a63bdd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/870fc81d2f879903dfc5b60bf8a0f94a1609e669", - "reference": "870fc81d2f879903dfc5b60bf8a0f94a1609e669", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/d1af40ac4a6ccc12bd062a7184f63c9995a63bdd", + "reference": "d1af40ac4a6ccc12bd062a7184f63c9995a63bdd", "shasum": "" }, "require": { @@ -2069,20 +2071,20 @@ "issues": "https://github.com/laravel/serializable-closure/issues", "source": "https://github.com/laravel/serializable-closure" }, - "time": "2026-02-20T19:59:49+00:00" + "time": "2026-04-07T13:32:18+00:00" }, { "name": "laravel/socialite", - "version": "v5.24.3", + "version": "v5.26.1", "source": { "type": "git", "url": "https://github.com/laravel/socialite.git", - "reference": "0feb62267e7b8abc68593ca37639ad302728c129" + "reference": "db6ec2ee967b7f06412c3a0cf1daaf072f4752a4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/socialite/zipball/0feb62267e7b8abc68593ca37639ad302728c129", - "reference": "0feb62267e7b8abc68593ca37639ad302728c129", + "url": "https://api.github.com/repos/laravel/socialite/zipball/db6ec2ee967b7f06412c3a0cf1daaf072f4752a4", + "reference": "db6ec2ee967b7f06412c3a0cf1daaf072f4752a4", "shasum": "" }, "require": { @@ -2141,7 +2143,7 @@ "issues": "https://github.com/laravel/socialite/issues", "source": "https://github.com/laravel/socialite" }, - "time": "2026-02-21T13:32:50+00:00" + "time": "2026-03-29T14:50:53+00:00" }, { "name": "laravel/tinker", @@ -2211,29 +2213,29 @@ }, { "name": "laravel/ui", - "version": "v4.6.1", + "version": "v4.6.3", "source": { "type": "git", "url": "https://github.com/laravel/ui.git", - "reference": "7d6ffa38d79f19c9b3e70a751a9af845e8f41d88" + "reference": "ff27db15416c1ed8ad9848f5692e47595dd5de27" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/ui/zipball/7d6ffa38d79f19c9b3e70a751a9af845e8f41d88", - "reference": "7d6ffa38d79f19c9b3e70a751a9af845e8f41d88", + "url": "https://api.github.com/repos/laravel/ui/zipball/ff27db15416c1ed8ad9848f5692e47595dd5de27", + "reference": "ff27db15416c1ed8ad9848f5692e47595dd5de27", "shasum": "" }, "require": { - "illuminate/console": "^9.21|^10.0|^11.0|^12.0", - "illuminate/filesystem": "^9.21|^10.0|^11.0|^12.0", - "illuminate/support": "^9.21|^10.0|^11.0|^12.0", - "illuminate/validation": "^9.21|^10.0|^11.0|^12.0", + "illuminate/console": "^9.21|^10.0|^11.0|^12.0|^13.0", + "illuminate/filesystem": "^9.21|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^9.21|^10.0|^11.0|^12.0|^13.0", + "illuminate/validation": "^9.21|^10.0|^11.0|^12.0|^13.0", "php": "^8.0", - "symfony/console": "^6.0|^7.0" + "symfony/console": "^6.0|^7.0|^8.0" }, "require-dev": { - "orchestra/testbench": "^7.35|^8.15|^9.0|^10.0", - "phpunit/phpunit": "^9.3|^10.4|^11.5" + "orchestra/testbench": "^7.35|^8.15|^9.0|^10.0|^11.0", + "phpunit/phpunit": "^9.3|^10.4|^11.5|^12.5|^13.0" }, "type": "library", "extra": { @@ -2268,9 +2270,9 @@ "ui" ], "support": { - "source": "https://github.com/laravel/ui/tree/v4.6.1" + "source": "https://github.com/laravel/ui/tree/v4.6.3" }, - "time": "2025-01-28T15:15:29+00:00" + "time": "2026-03-17T13:41:52+00:00" }, { "name": "lcobucci/clock", @@ -2411,16 +2413,16 @@ }, { "name": "league/commonmark", - "version": "2.8.0", + "version": "2.8.2", "source": { "type": "git", "url": "https://github.com/thephpleague/commonmark.git", - "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb" + "reference": "59fb075d2101740c337c7216e3f32b36c204218b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/4efa10c1e56488e658d10adf7b7b7dcd19940bfb", - "reference": "4efa10c1e56488e658d10adf7b7b7dcd19940bfb", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/59fb075d2101740c337c7216e3f32b36c204218b", + "reference": "59fb075d2101740c337c7216e3f32b36c204218b", "shasum": "" }, "require": { @@ -2445,9 +2447,9 @@ "phpstan/phpstan": "^1.8.2", "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0", "scrutinizer/ocular": "^1.8.1", - "symfony/finder": "^5.3 | ^6.0 | ^7.0", - "symfony/process": "^5.4 | ^6.0 | ^7.0", - "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", "unleashedtech/php-coding-standard": "^3.1.1", "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" }, @@ -2514,7 +2516,7 @@ "type": "tidelift" } ], - "time": "2025-11-26T21:48:24+00:00" + "time": "2026-03-19T13:16:38+00:00" }, { "name": "league/config", @@ -2659,16 +2661,16 @@ }, { "name": "league/flysystem", - "version": "3.32.0", + "version": "3.33.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725" + "reference": "570b8871e0ce693764434b29154c54b434905350" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/254b1595b16b22dbddaaef9ed6ca9fdac4956725", - "reference": "254b1595b16b22dbddaaef9ed6ca9fdac4956725", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350", + "reference": "570b8871e0ce693764434b29154c54b434905350", "shasum": "" }, "require": { @@ -2736,9 +2738,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.32.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.33.0" }, - "time": "2026-02-25T17:01:41+00:00" + "time": "2026-03-25T07:59:30+00:00" }, { "name": "league/flysystem-local", @@ -3019,20 +3021,20 @@ }, { "name": "league/uri", - "version": "7.8.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri.git", - "reference": "4436c6ec8d458e4244448b069cc572d088230b76" + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri/zipball/4436c6ec8d458e4244448b069cc572d088230b76", - "reference": "4436c6ec8d458e4244448b069cc572d088230b76", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", "shasum": "" }, "require": { - "league/uri-interfaces": "^7.8", + "league/uri-interfaces": "^7.8.1", "php": "^8.1", "psr/http-factory": "^1" }, @@ -3105,7 +3107,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri/tree/7.8.0" + "source": "https://github.com/thephpleague/uri/tree/7.8.1" }, "funding": [ { @@ -3113,20 +3115,20 @@ "type": "github" } ], - "time": "2026-01-14T17:24:56+00:00" + "time": "2026-03-15T20:22:25+00:00" }, { "name": "league/uri-interfaces", - "version": "7.8.0", + "version": "7.8.1", "source": { "type": "git", "url": "https://github.com/thephpleague/uri-interfaces.git", - "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4" + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/c5c5cd056110fc8afaba29fa6b72a43ced42acd4", - "reference": "c5c5cd056110fc8afaba29fa6b72a43ced42acd4", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", "shasum": "" }, "require": { @@ -3189,7 +3191,7 @@ "docs": "https://uri.thephpleague.com", "forum": "https://thephpleague.slack.com", "issues": "https://github.com/thephpleague/uri-src/issues", - "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.0" + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" }, "funding": [ { @@ -3197,33 +3199,33 @@ "type": "github" } ], - "time": "2026-01-15T06:54:53+00:00" + "time": "2026-03-08T20:05:35+00:00" }, { "name": "maatwebsite/excel", - "version": "3.1.67", + "version": "3.1.68", "source": { "type": "git", "url": "https://github.com/SpartnerNL/Laravel-Excel.git", - "reference": "e508e34a502a3acc3329b464dad257378a7edb4d" + "reference": "1854739267d81d38eae7d8c623caf523f30f256b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/e508e34a502a3acc3329b464dad257378a7edb4d", - "reference": "e508e34a502a3acc3329b464dad257378a7edb4d", + "url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/1854739267d81d38eae7d8c623caf523f30f256b", + "reference": "1854739267d81d38eae7d8c623caf523f30f256b", "shasum": "" }, "require": { "composer/semver": "^3.3", "ext-json": "*", - "illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0||^12.0", + "illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0||^12.0||^13.0", "php": "^7.0||^8.0", "phpoffice/phpspreadsheet": "^1.30.0", "psr/simple-cache": "^1.0||^2.0||^3.0" }, "require-dev": { - "laravel/scout": "^7.0||^8.0||^9.0||^10.0", - "orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0", + "laravel/scout": "^7.0||^8.0||^9.0||^10.0||^11.0", + "orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0||^11.0", "predis/predis": "^1.1" }, "type": "library", @@ -3266,7 +3268,7 @@ ], "support": { "issues": "https://github.com/SpartnerNL/Laravel-Excel/issues", - "source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.67" + "source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.68" }, "funding": [ { @@ -3278,7 +3280,7 @@ "type": "github" } ], - "time": "2025-08-26T09:13:16+00:00" + "time": "2026-03-17T20:51:10+00:00" }, { "name": "maennchen/zipstream-php", @@ -3570,16 +3572,16 @@ }, { "name": "nesbot/carbon", - "version": "3.11.1", + "version": "3.11.4", "source": { "type": "git", "url": "https://github.com/CarbonPHP/carbon.git", - "reference": "f438fcc98f92babee98381d399c65336f3a3827f" + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/f438fcc98f92babee98381d399c65336f3a3827f", - "reference": "f438fcc98f92babee98381d399c65336f3a3827f", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/e890471a3494740f7d9326d72ce6a8c559ffee60", + "reference": "e890471a3494740f7d9326d72ce6a8c559ffee60", "shasum": "" }, "require": { @@ -3671,7 +3673,7 @@ "type": "tidelift" } ], - "time": "2026-01-29T09:26:29+00:00" + "time": "2026-04-07T09:57:54+00:00" }, { "name": "nette/schema", @@ -4600,16 +4602,16 @@ }, { "name": "phpseclib/phpseclib", - "version": "3.0.49", + "version": "3.0.50", "source": { "type": "git", "url": "https://github.com/phpseclib/phpseclib.git", - "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9" + "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/6233a1e12584754e6b5daa69fe1289b47775c1b9", - "reference": "6233a1e12584754e6b5daa69fe1289b47775c1b9", + "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", + "reference": "aa6ad8321ed103dc3624fb600a25b66ebf78ec7b", "shasum": "" }, "require": { @@ -4690,7 +4692,7 @@ ], "support": { "issues": "https://github.com/phpseclib/phpseclib/issues", - "source": "https://github.com/phpseclib/phpseclib/tree/3.0.49" + "source": "https://github.com/phpseclib/phpseclib/tree/3.0.50" }, "funding": [ { @@ -4706,7 +4708,7 @@ "type": "tidelift" } ], - "time": "2026-01-27T09:17:28+00:00" + "time": "2026-03-19T02:57:58+00:00" }, { "name": "psr/clock", @@ -5235,16 +5237,16 @@ }, { "name": "psy/psysh", - "version": "v0.12.20", + "version": "v0.12.22", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373" + "reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/19678eb6b952a03b8a1d96ecee9edba518bb0373", - "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/3be75d5b9244936dd4ac62ade2bfb004d13acf0f", + "reference": "3be75d5b9244936dd4ac62ade2bfb004d13acf0f", "shasum": "" }, "require": { @@ -5308,9 +5310,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.20" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.22" }, - "time": "2026-02-11T15:05:28+00:00" + "time": "2026-03-22T23:03:24+00:00" }, { "name": "ralouphie/getallheaders", @@ -5562,22 +5564,22 @@ }, { "name": "socialiteproviders/manager", - "version": "v4.8.1", + "version": "4.9.2", "source": { "type": "git", "url": "https://github.com/SocialiteProviders/Manager.git", - "reference": "8180ec14bef230ec2351cff993d5d2d7ca470ef4" + "reference": "35372dc62787e61e91cfec73f45fd5d5ae0f8891" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/8180ec14bef230ec2351cff993d5d2d7ca470ef4", - "reference": "8180ec14bef230ec2351cff993d5d2d7ca470ef4", + "url": "https://api.github.com/repos/SocialiteProviders/Manager/zipball/35372dc62787e61e91cfec73f45fd5d5ae0f8891", + "reference": "35372dc62787e61e91cfec73f45fd5d5ae0f8891", "shasum": "" }, "require": { - "illuminate/support": "^8.0 || ^9.0 || ^10.0 || ^11.0 || ^12.0", + "illuminate/support": "^11.0 || ^12.0 || ^13.0", "laravel/socialite": "^5.5", - "php": "^8.1" + "php": "^8.2" }, "require-dev": { "mockery/mockery": "^1.2", @@ -5632,20 +5634,20 @@ "issues": "https://github.com/socialiteproviders/manager/issues", "source": "https://github.com/socialiteproviders/manager" }, - "time": "2025-02-24T19:33:30+00:00" + "time": "2026-03-18T22:13:24+00:00" }, { "name": "symfony/clock", - "version": "v8.0.0", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/clock.git", - "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f" + "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/clock/zipball/832119f9b8dbc6c8e6f65f30c5969eca1e88764f", - "reference": "832119f9b8dbc6c8e6f65f30c5969eca1e88764f", + "url": "https://api.github.com/repos/symfony/clock/zipball/b55a638b189a6faa875e0ccdb00908fb87af95b3", + "reference": "b55a638b189a6faa875e0ccdb00908fb87af95b3", "shasum": "" }, "require": { @@ -5689,7 +5691,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v8.0.0" + "source": "https://github.com/symfony/clock/tree/v8.0.8" }, "funding": [ { @@ -5709,20 +5711,20 @@ "type": "tidelift" } ], - "time": "2025-11-12T15:46:48+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/console", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "6d643a93b47398599124022eb24d97c153c12f27" + "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/6d643a93b47398599124022eb24d97c153c12f27", - "reference": "6d643a93b47398599124022eb24d97c153c12f27", + "url": "https://api.github.com/repos/symfony/console/zipball/1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707", + "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707", "shasum": "" }, "require": { @@ -5787,7 +5789,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.6" + "source": "https://github.com/symfony/console/tree/v7.4.8" }, "funding": [ { @@ -5807,20 +5809,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T17:02:47+00:00" + "time": "2026-03-30T13:54:39+00:00" }, { "name": "symfony/css-selector", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "2a178bf80f05dbbe469a337730eba79d61315262" + "reference": "8db1c00226a94d8ab6aa89d9224eeee91e2ea2ed" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/2a178bf80f05dbbe469a337730eba79d61315262", - "reference": "2a178bf80f05dbbe469a337730eba79d61315262", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/8db1c00226a94d8ab6aa89d9224eeee91e2ea2ed", + "reference": "8db1c00226a94d8ab6aa89d9224eeee91e2ea2ed", "shasum": "" }, "require": { @@ -5856,7 +5858,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.0.6" + "source": "https://github.com/symfony/css-selector/tree/v8.0.8" }, "funding": [ { @@ -5876,7 +5878,7 @@ "type": "tidelift" } ], - "time": "2026-02-17T13:07:04+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/deprecation-contracts", @@ -5947,16 +5949,16 @@ }, { "name": "symfony/error-handler", - "version": "v7.4.4", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/error-handler.git", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8" + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/error-handler/zipball/8da531f364ddfee53e36092a7eebbbd0b775f6b8", - "reference": "8da531f364ddfee53e36092a7eebbbd0b775f6b8", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", + "reference": "8dd79d8af777ee6cba2fd4d98da6ffb839f3c0fa", "shasum": "" }, "require": { @@ -6005,7 +6007,7 @@ "description": "Provides tools to manage errors and ease debugging PHP code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/error-handler/tree/v7.4.4" + "source": "https://github.com/symfony/error-handler/tree/v7.4.8" }, "funding": [ { @@ -6025,20 +6027,20 @@ "type": "tidelift" } ], - "time": "2026-01-20T16:42:42+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v8.0.4", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "99301401da182b6cfaa4700dbe9987bb75474b47" + "reference": "f662acc6ab22a3d6d716dcb44c381c6002940df6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/99301401da182b6cfaa4700dbe9987bb75474b47", - "reference": "99301401da182b6cfaa4700dbe9987bb75474b47", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f662acc6ab22a3d6d716dcb44c381c6002940df6", + "reference": "f662acc6ab22a3d6d716dcb44c381c6002940df6", "shasum": "" }, "require": { @@ -6090,7 +6092,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.4" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.8" }, "funding": [ { @@ -6110,7 +6112,7 @@ "type": "tidelift" } ], - "time": "2026-01-05T11:45:55+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -6190,16 +6192,16 @@ }, { "name": "symfony/finder", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf" + "reference": "e0be088d22278583a82da281886e8c3592fbf149" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/8655bf1076b7a3a346cb11413ffdabff50c7ffcf", - "reference": "8655bf1076b7a3a346cb11413ffdabff50c7ffcf", + "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149", + "reference": "e0be088d22278583a82da281886e8c3592fbf149", "shasum": "" }, "require": { @@ -6234,7 +6236,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.6" + "source": "https://github.com/symfony/finder/tree/v7.4.8" }, "funding": [ { @@ -6254,20 +6256,20 @@ "type": "tidelift" } ], - "time": "2026-01-29T09:40:50+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "fd97d5e926e988a363cef56fbbf88c5c528e9065" + "reference": "9381209597ec66c25be154cbf2289076e64d1eab" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/fd97d5e926e988a363cef56fbbf88c5c528e9065", - "reference": "fd97d5e926e988a363cef56fbbf88c5c528e9065", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/9381209597ec66c25be154cbf2289076e64d1eab", + "reference": "9381209597ec66c25be154cbf2289076e64d1eab", "shasum": "" }, "require": { @@ -6316,7 +6318,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.6" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.8" }, "funding": [ { @@ -6336,20 +6338,20 @@ "type": "tidelift" } ], - "time": "2026-02-21T16:25:55+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "002ac0cf4cd972a7fd0912dcd513a95e8a81ce83" + "reference": "017e76ad089bac281553389269e259e155935e1a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/002ac0cf4cd972a7fd0912dcd513a95e8a81ce83", - "reference": "002ac0cf4cd972a7fd0912dcd513a95e8a81ce83", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/017e76ad089bac281553389269e259e155935e1a", + "reference": "017e76ad089bac281553389269e259e155935e1a", "shasum": "" }, "require": { @@ -6435,7 +6437,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.6" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.8" }, "funding": [ { @@ -6455,20 +6457,20 @@ "type": "tidelift" } ], - "time": "2026-02-26T08:30:57+00:00" + "time": "2026-03-31T20:57:01+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9" + "reference": "f6ea532250b476bfc1b56699b388a1bdbf168f62" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/b02726f39a20bc65e30364f5c750c4ddbf1f58e9", - "reference": "b02726f39a20bc65e30364f5c750c4ddbf1f58e9", + "url": "https://api.github.com/repos/symfony/mailer/zipball/f6ea532250b476bfc1b56699b388a1bdbf168f62", + "reference": "f6ea532250b476bfc1b56699b388a1bdbf168f62", "shasum": "" }, "require": { @@ -6519,7 +6521,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.6" + "source": "https://github.com/symfony/mailer/tree/v7.4.8" }, "funding": [ { @@ -6539,20 +6541,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/mime", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "9fc881d95feae4c6c48678cb6372bd8a7ba04f5f" + "reference": "6df02f99998081032da3407a8d6c4e1dcb5d4379" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/9fc881d95feae4c6c48678cb6372bd8a7ba04f5f", - "reference": "9fc881d95feae4c6c48678cb6372bd8a7ba04f5f", + "url": "https://api.github.com/repos/symfony/mime/zipball/6df02f99998081032da3407a8d6c4e1dcb5d4379", + "reference": "6df02f99998081032da3407a8d6c4e1dcb5d4379", "shasum": "" }, "require": { @@ -6608,7 +6610,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.6" + "source": "https://github.com/symfony/mime/tree/v7.4.8" }, "funding": [ { @@ -6628,7 +6630,7 @@ "type": "tidelift" } ], - "time": "2026-02-05T15:57:06+00:00" + "time": "2026-03-30T14:11:46+00:00" }, { "name": "symfony/polyfill-ctype", @@ -7381,16 +7383,16 @@ }, { "name": "symfony/process", - "version": "v7.4.5", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "608476f4604102976d687c483ac63a79ba18cc97" + "reference": "60f19cd3badc8de688421e21e4305eba50f8089a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/608476f4604102976d687c483ac63a79ba18cc97", - "reference": "608476f4604102976d687c483ac63a79ba18cc97", + "url": "https://api.github.com/repos/symfony/process/zipball/60f19cd3badc8de688421e21e4305eba50f8089a", + "reference": "60f19cd3badc8de688421e21e4305eba50f8089a", "shasum": "" }, "require": { @@ -7422,7 +7424,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.5" + "source": "https://github.com/symfony/process/tree/v7.4.8" }, "funding": [ { @@ -7442,20 +7444,20 @@ "type": "tidelift" } ], - "time": "2026-01-26T15:07:59+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/psr-http-message-bridge", - "version": "v8.0.4", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/psr-http-message-bridge.git", - "reference": "d6edf266746dd0b8e81e754a79da77b08dc00531" + "reference": "94facc221260c1d5f20e31ee43cd6c6a824b4a19" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/d6edf266746dd0b8e81e754a79da77b08dc00531", - "reference": "d6edf266746dd0b8e81e754a79da77b08dc00531", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/94facc221260c1d5f20e31ee43cd6c6a824b4a19", + "reference": "94facc221260c1d5f20e31ee43cd6c6a824b4a19", "shasum": "" }, "require": { @@ -7509,7 +7511,7 @@ "psr-7" ], "support": { - "source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.0.4" + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.0.8" }, "funding": [ { @@ -7529,20 +7531,20 @@ "type": "tidelift" } ], - "time": "2026-01-03T23:40:55+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/routing", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "238d749c56b804b31a9bf3e26519d93b65a60938" + "reference": "9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/238d749c56b804b31a9bf3e26519d93b65a60938", - "reference": "238d749c56b804b31a9bf3e26519d93b65a60938", + "url": "https://api.github.com/repos/symfony/routing/zipball/9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b", + "reference": "9608de9873ec86e754fb6c0a0fa7e5f1a960eb6b", "shasum": "" }, "require": { @@ -7594,7 +7596,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.6" + "source": "https://github.com/symfony/routing/tree/v7.4.8" }, "funding": [ { @@ -7614,7 +7616,7 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:50:00+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/service-contracts", @@ -7705,16 +7707,16 @@ }, { "name": "symfony/string", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4" + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/6c9e1108041b5dce21a9a4984b531c4923aa9ec4", - "reference": "6c9e1108041b5dce21a9a4984b531c4923aa9ec4", + "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", + "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", "shasum": "" }, "require": { @@ -7771,7 +7773,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.6" + "source": "https://github.com/symfony/string/tree/v8.0.8" }, "funding": [ { @@ -7791,20 +7793,20 @@ "type": "tidelift" } ], - "time": "2026-02-09T10:14:57+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/translation", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b" + "reference": "27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", - "reference": "13ff19bcf2bea492d3c2fbeaa194dd6f4599ce1b", + "url": "https://api.github.com/repos/symfony/translation/zipball/27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f", + "reference": "27c03ae3940de24ba2f71cfdbac824f2aa1fdf2f", "shasum": "" }, "require": { @@ -7864,7 +7866,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.6" + "source": "https://github.com/symfony/translation/tree/v8.0.8" }, "funding": [ { @@ -7884,7 +7886,7 @@ "type": "tidelift" } ], - "time": "2026-02-17T13:07:04+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/translation-contracts", @@ -7970,16 +7972,16 @@ }, { "name": "symfony/uid", - "version": "v7.4.4", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/uid.git", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36" + "reference": "6883ebdf7bf6a12b37519dbc0df62b0222401b56" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/uid/zipball/7719ce8aba76be93dfe249192f1fbfa52c588e36", - "reference": "7719ce8aba76be93dfe249192f1fbfa52c588e36", + "url": "https://api.github.com/repos/symfony/uid/zipball/6883ebdf7bf6a12b37519dbc0df62b0222401b56", + "reference": "6883ebdf7bf6a12b37519dbc0df62b0222401b56", "shasum": "" }, "require": { @@ -8024,7 +8026,7 @@ "uuid" ], "support": { - "source": "https://github.com/symfony/uid/tree/v7.4.4" + "source": "https://github.com/symfony/uid/tree/v7.4.8" }, "funding": [ { @@ -8044,20 +8046,20 @@ "type": "tidelift" } ], - "time": "2026-01-03T23:30:35+00:00" + "time": "2026-03-24T13:12:05+00:00" }, { "name": "symfony/var-dumper", - "version": "v7.4.6", + "version": "v7.4.8", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291" + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/045321c440ac18347b136c63d2e9bf28a2dc0291", - "reference": "045321c440ac18347b136c63d2e9bf28a2dc0291", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/9510c3966f749a1d1ff0059e1eabef6cc621e7fd", + "reference": "9510c3966f749a1d1ff0059e1eabef6cc621e7fd", "shasum": "" }, "require": { @@ -8111,7 +8113,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v7.4.6" + "source": "https://github.com/symfony/var-dumper/tree/v7.4.8" }, "funding": [ { @@ -8131,7 +8133,7 @@ "type": "tidelift" } ], - "time": "2026-02-15T10:53:20+00:00" + "time": "2026-03-30T13:44:50+00:00" }, { "name": "tijsverkoyen/css-to-inline-styles", @@ -9169,16 +9171,16 @@ }, { "name": "laravel/pint", - "version": "v1.27.1", + "version": "v1.29.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5" + "reference": "bdec963f53172c5e36330f3a400604c69bf02d39" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/54cca2de13790570c7b6f0f94f37896bee4abcb5", - "reference": "54cca2de13790570c7b6f0f94f37896bee4abcb5", + "url": "https://api.github.com/repos/laravel/pint/zipball/bdec963f53172c5e36330f3a400604c69bf02d39", + "reference": "bdec963f53172c5e36330f3a400604c69bf02d39", "shasum": "" }, "require": { @@ -9189,13 +9191,14 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.93.1", - "illuminate/view": "^12.51.0", - "larastan/larastan": "^3.9.2", + "friendsofphp/php-cs-fixer": "^3.94.2", + "illuminate/view": "^12.54.1", + "larastan/larastan": "^3.9.3", "laravel-zero/framework": "^12.0.5", "mockery/mockery": "^1.6.12", - "nunomaduro/termwind": "^2.3.3", - "pestphp/pest": "^3.8.5" + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^3.8.6", + "shipfastlabs/agent-detector": "^1.1.0" }, "bin": [ "builds/pint" @@ -9232,20 +9235,20 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2026-02-10T20:00:20+00:00" + "time": "2026-03-12T15:51:39+00:00" }, { "name": "laravel/sail", - "version": "v1.53.0", + "version": "v1.56.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb" + "reference": "f43426bb42a1cb7a51a3861d9138063e54766d28" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/e340eaa2bea9b99192570c48ed837155dbf24fbb", - "reference": "e340eaa2bea9b99192570c48ed837155dbf24fbb", + "url": "https://api.github.com/repos/laravel/sail/zipball/f43426bb42a1cb7a51a3861d9138063e54766d28", + "reference": "f43426bb42a1cb7a51a3861d9138063e54766d28", "shasum": "" }, "require": { @@ -9295,25 +9298,26 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2026-02-06T12:16:02+00:00" + "time": "2026-04-01T15:17:32+00:00" }, { "name": "league/container", - "version": "5.1.0", + "version": "5.2.0", "source": { "type": "git", "url": "https://github.com/thephpleague/container.git", - "reference": "041c52d266763887fff2256fb5dc9392d808f8f3" + "reference": "58accbc032f0090a9bd08326f93062c5a658b2c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/container/zipball/041c52d266763887fff2256fb5dc9392d808f8f3", - "reference": "041c52d266763887fff2256fb5dc9392d808f8f3", + "url": "https://api.github.com/repos/thephpleague/container/zipball/58accbc032f0090a9bd08326f93062c5a658b2c5", + "reference": "58accbc032f0090a9bd08326f93062c5a658b2c5", "shasum": "" }, "require": { "php": "^8.1", - "psr/container": "^2.0.2" + "psr/container": "^2.0.2", + "psr/event-dispatcher": "^1.0" }, "provide": { "psr/container-implementation": "^1.0" @@ -9370,7 +9374,7 @@ ], "support": { "issues": "https://github.com/thephpleague/container/issues", - "source": "https://github.com/thephpleague/container/tree/5.1.0" + "source": "https://github.com/thephpleague/container/tree/5.2.0" }, "funding": [ { @@ -9378,7 +9382,7 @@ "type": "github" } ], - "time": "2025-05-28T07:37:56+00:00" + "time": "2026-03-19T18:52:39+00:00" }, { "name": "mockery/mockery", @@ -9525,23 +9529,23 @@ }, { "name": "nunomaduro/collision", - "version": "v8.9.1", + "version": "v8.9.3", "source": { "type": "git", "url": "https://github.com/nunomaduro/collision.git", - "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935" + "reference": "b0d8ab95b29c3189aeeb902d81215231df4c1b64" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nunomaduro/collision/zipball/a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", - "reference": "a1ed3fa530fd60bc515f9303e8520fcb7d4bd935", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/b0d8ab95b29c3189aeeb902d81215231df4c1b64", + "reference": "b0d8ab95b29c3189aeeb902d81215231df4c1b64", "shasum": "" }, "require": { "filp/whoops": "^2.18.4", "nunomaduro/termwind": "^2.4.0", "php": "^8.2.0", - "symfony/console": "^7.4.4 || ^8.0.4" + "symfony/console": "^7.4.8 || ^8.0.4" }, "conflict": { "laravel/framework": "<11.48.0 || >=14.0.0", @@ -9549,12 +9553,12 @@ }, "require-dev": { "brianium/paratest": "^7.8.5", - "larastan/larastan": "^3.9.2", - "laravel/framework": "^11.48.0 || ^12.52.0", - "laravel/pint": "^1.27.1", - "orchestra/testbench-core": "^9.12.0 || ^10.9.0", - "pestphp/pest": "^3.8.5 || ^4.4.1 || ^5.0.0", - "sebastian/environment": "^7.2.1 || ^8.0.3 || ^9.0.0" + "larastan/larastan": "^3.9.3", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.2.0", + "laravel/pint": "^1.29.0", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.0.0", + "pestphp/pest": "^3.8.5 || ^4.4.3 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.0.4 || ^9.0.0" }, "type": "library", "extra": { @@ -9617,7 +9621,7 @@ "type": "patreon" } ], - "time": "2026-02-17T17:33:08+00:00" + "time": "2026-04-06T19:25:53+00:00" }, { "name": "nunomaduro/phpinsights", @@ -9941,11 +9945,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.40", + "version": "2.1.46", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", - "reference": "9b2c7aeb83a75d8680ea5e7c9b7fca88052b766b", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", + "reference": "a193923fc2d6325ef4e741cf3af8c3e8f54dbf25", "shasum": "" }, "require": { @@ -9990,7 +9994,7 @@ "type": "github" } ], - "time": "2026-02-23T15:04:35+00:00" + "time": "2026-04-01T09:25:14+00:00" }, { "name": "phpunit/php-code-coverage", @@ -12208,16 +12212,16 @@ }, { "name": "symfony/cache", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/cache.git", - "reference": "59184fa14658d7724cd9b8743d91c1b1aa618bff" + "reference": "8abf3ccbeae9d3071b81a3ae7ee11b209f9e1e78" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/cache/zipball/59184fa14658d7724cd9b8743d91c1b1aa618bff", - "reference": "59184fa14658d7724cd9b8743d91c1b1aa618bff", + "url": "https://api.github.com/repos/symfony/cache/zipball/8abf3ccbeae9d3071b81a3ae7ee11b209f9e1e78", + "reference": "8abf3ccbeae9d3071b81a3ae7ee11b209f9e1e78", "shasum": "" }, "require": { @@ -12284,7 +12288,7 @@ "psr6" ], "support": { - "source": "https://github.com/symfony/cache/tree/v8.0.6" + "source": "https://github.com/symfony/cache/tree/v8.0.8" }, "funding": [ { @@ -12304,7 +12308,7 @@ "type": "tidelift" } ], - "time": "2026-02-21T23:29:37+00:00" + "time": "2026-03-30T15:18:51+00:00" }, { "name": "symfony/cache-contracts", @@ -12384,16 +12388,16 @@ }, { "name": "symfony/filesystem", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "7bf9162d7a0dff98d079b72948508fa48018a770" + "reference": "66b769ae743ce2d13e435528fbef4af03d623e5a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/7bf9162d7a0dff98d079b72948508fa48018a770", - "reference": "7bf9162d7a0dff98d079b72948508fa48018a770", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/66b769ae743ce2d13e435528fbef4af03d623e5a", + "reference": "66b769ae743ce2d13e435528fbef4af03d623e5a", "shasum": "" }, "require": { @@ -12430,7 +12434,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v8.0.6" + "source": "https://github.com/symfony/filesystem/tree/v8.0.8" }, "funding": [ { @@ -12450,20 +12454,20 @@ "type": "tidelift" } ], - "time": "2026-02-25T16:59:43+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/http-client", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "f425139487f904e198f99e3c416c79ed08cef3c3" + "reference": "356e43d6994ae9d7761fd404d40f78691deabe0e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/f425139487f904e198f99e3c416c79ed08cef3c3", - "reference": "f425139487f904e198f99e3c416c79ed08cef3c3", + "url": "https://api.github.com/repos/symfony/http-client/zipball/356e43d6994ae9d7761fd404d40f78691deabe0e", + "reference": "356e43d6994ae9d7761fd404d40f78691deabe0e", "shasum": "" }, "require": { @@ -12526,7 +12530,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v8.0.6" + "source": "https://github.com/symfony/http-client/tree/v8.0.8" }, "funding": [ { @@ -12546,7 +12550,7 @@ "type": "tidelift" } ], - "time": "2026-02-20T07:51:53+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/http-client-contracts", @@ -12628,16 +12632,16 @@ }, { "name": "symfony/options-resolver", - "version": "v8.0.0", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/options-resolver.git", - "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7" + "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/options-resolver/zipball/d2b592535ffa6600c265a3893a7f7fd2bad82dd7", - "reference": "d2b592535ffa6600c265a3893a7f7fd2bad82dd7", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/b48bce0a70b914f6953dafbd10474df232ed4de8", + "reference": "b48bce0a70b914f6953dafbd10474df232ed4de8", "shasum": "" }, "require": { @@ -12675,7 +12679,7 @@ "options" ], "support": { - "source": "https://github.com/symfony/options-resolver/tree/v8.0.0" + "source": "https://github.com/symfony/options-resolver/tree/v8.0.8" }, "funding": [ { @@ -12695,7 +12699,7 @@ "type": "tidelift" } ], - "time": "2025-11-12T15:55:31+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/polyfill-php81", @@ -12859,16 +12863,16 @@ }, { "name": "symfony/stopwatch", - "version": "v8.0.0", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/stopwatch.git", - "reference": "67df1914c6ccd2d7b52f70d40cf2aea02159d942" + "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/stopwatch/zipball/67df1914c6ccd2d7b52f70d40cf2aea02159d942", - "reference": "67df1914c6ccd2d7b52f70d40cf2aea02159d942", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/85954ed72d5440ea4dc9a10b7e49e01df766ffa3", + "reference": "85954ed72d5440ea4dc9a10b7e49e01df766ffa3", "shasum": "" }, "require": { @@ -12901,7 +12905,7 @@ "description": "Provides a way to profile code", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/stopwatch/tree/v8.0.0" + "source": "https://github.com/symfony/stopwatch/tree/v8.0.8" }, "funding": [ { @@ -12921,20 +12925,20 @@ "type": "tidelift" } ], - "time": "2025-08-04T07:36:47+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/var-exporter", - "version": "v8.0.0", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/var-exporter.git", - "reference": "7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04" + "reference": "15776bb07a91b089037da89f8832fa41d5fa6ec6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-exporter/zipball/7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04", - "reference": "7345f46c251f2eb27c7b3ebdb5bb076b3ffcae04", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/15776bb07a91b089037da89f8832fa41d5fa6ec6", + "reference": "15776bb07a91b089037da89f8832fa41d5fa6ec6", "shasum": "" }, "require": { @@ -12981,7 +12985,7 @@ "serialize" ], "support": { - "source": "https://github.com/symfony/var-exporter/tree/v8.0.0" + "source": "https://github.com/symfony/var-exporter/tree/v8.0.8" }, "funding": [ { @@ -13001,20 +13005,20 @@ "type": "tidelift" } ], - "time": "2025-11-05T18:53:00+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "symfony/yaml", - "version": "v8.0.6", + "version": "v8.0.8", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "5f006c50a981e1630bbb70ad409c5d85f9a716e0" + "reference": "54174ab48c0c0f9e21512b304be17f8150ccf8f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/5f006c50a981e1630bbb70ad409c5d85f9a716e0", - "reference": "5f006c50a981e1630bbb70ad409c5d85f9a716e0", + "url": "https://api.github.com/repos/symfony/yaml/zipball/54174ab48c0c0f9e21512b304be17f8150ccf8f1", + "reference": "54174ab48c0c0f9e21512b304be17f8150ccf8f1", "shasum": "" }, "require": { @@ -13056,7 +13060,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.0.6" + "source": "https://github.com/symfony/yaml/tree/v8.0.8" }, "funding": [ { @@ -13076,7 +13080,7 @@ "type": "tidelift" } ], - "time": "2026-02-09T10:14:57+00:00" + "time": "2026-03-30T15:14:47+00:00" }, { "name": "theseer/tokenizer", diff --git a/config/app.php b/config/app.php index 71942beb..084c2212 100644 --- a/config/app.php +++ b/config/app.php @@ -67,7 +67,8 @@ 'asset_url' => env('ASSET_URL', null), - 'editor' => env('APP_EDITOR', 'sublime'), + // set banner in test environment + 'test' => (bool) env('APP_BANNER_TEST', false), /* |-------------------------------------------------------------------------- diff --git a/database/migrations/2026_04_07_151247_create_risk_table.php b/database/migrations/2026_04_07_151247_create_risk_table.php new file mode 100644 index 00000000..7cf685c2 --- /dev/null +++ b/database/migrations/2026_04_07_151247_create_risk_table.php @@ -0,0 +1,94 @@ + +id(); + + // Identification + $table->string('name'); + $table->text('description')->nullable(); + + // Propriétaire du risque (responsable de la revue) + $table->unsignedInteger('owner_id') + ->nullable() + ->constrained('users') + ->nullOnDelete(); + + // Évaluation — Probabilité 1 à 5 + $table->tinyInteger('probability')->default(1); + $table->text('probability_comment')->nullable(); + + // Évaluation — Impact 1 à 5 + $table->tinyInteger('impact')->default(1); + $table->text('impact_comment')->nullable(); + + // Traitement du risque + $table->enum('status', [ + 'not_evaluated', // Non évalué + 'not_accepted', // Non accepté → plan d'action obligatoire + 'temporarily_accepted', // Accepté temporairement + 'accepted', // Accepté + 'mitigated', // Mitigé → contrôles liés obligatoires + 'transferred', // Transféré (assurance, tiers) + 'avoided', // Évité + ])->default('not_evaluated'); + $table->text('status_comment')->nullable(); + + // Planification des revues + $table->unsignedSmallInteger('review_frequency')->default(12); // mois + $table->date('next_review_at')->nullable(); + + // --- Champs réservés v2 (BSI 200-3 / ISACA scoring) --- + // Exposure : 0 = offline, 1 = réseau interne, 2 = Internet + $table->tinyInteger('exposure')->nullable()->comment('v2 - BSI 200-3'); + // Vulnerability : 1 = aucune, 2 = connue non exploitable, 3 = exploitable interne, 4 = exploitable externe + $table->tinyInteger('vulnerability')->nullable()->comment('v2 - BSI 200-3'); + // likelihood et risk_score sont calculés (non stockés en base) + + $table->timestamps(); + $table->softDeletes(); + + // Index utiles pour les filtrages fréquents + $table->index('status'); + $table->index('owner_id'); + $table->index('next_review_at'); + }); + + // Table pivot : risques ↔ mesures de contrôle + // Obligatoire si status = 'mitigated' + Schema::create('measure_risk', function (Blueprint $table) { + $table->unsignedInteger('risk_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('measure_id')->constrained()->cascadeOnDelete(); + $table->primary(['risk_id', 'measure_id']); + }); + + // Table pivot : risques ↔ plans d'action + // Obligatoire si status = 'not_accepted' + Schema::create('action_risk', function (Blueprint $table) { + $table->unsignedInteger('risk_id')->constrained()->cascadeOnDelete(); + $table->unsignedInteger('action_id')->constrained()->cascadeOnDelete(); + $table->primary(['risk_id', 'action_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('action_risk'); + Schema::dropIfExists('measure_risk'); + Schema::dropIfExists('risks'); + } +}; \ No newline at end of file diff --git a/database/migrations/2026_04_07_152854_create_risk_scoring_table.php b/database/migrations/2026_04_07_152854_create_risk_scoring_table.php new file mode 100644 index 00000000..93e92a9c --- /dev/null +++ b/database/migrations/2026_04_07_152854_create_risk_scoring_table.php @@ -0,0 +1,82 @@ +id(); + $table->string('name'); // Libellé affiché dans l'UI + $table->string('formula'); // Voir RiskScoringService::FORMULAS + $table->boolean('is_active')->default(false)->index(); + + // Niveaux configurables — tableaux JSON d'objets : + // [{"value": 1, "label": "Rare", "description": "..."}] + // Pour likelihood_x_impact : exposure + vulnerability remplacent probability. + $table->json('probability_levels'); // Utilisé par : probability_x_impact, additive, max_pi + $table->json('impact_levels'); // Utilisé par toutes les formules + $table->json('exposure_levels')->nullable(); // Utilisé par : likelihood_x_impact + $table->json('vulnerability_levels')->nullable(); // Utilisé par : likelihood_x_impact + + // Seuils de classification du score final + // [{"level": "low", "label": "Faible", "max": 4, "color": "success"}, ...] + // Le dernier seuil doit avoir "max": null (= pas de borne supérieure) + $table->json('risk_thresholds'); + + $table->timestamps(); + }); + + // Insérer la configuration par défaut (ISO 27005 classique, alignée lb-consult) + DB::table('risk_scoring_configs')->insert([ + 'name' => 'ISO 27005 — Probabilité × Impact (défaut)', + 'formula' => 'probability_x_impact', + 'is_active' => true, + + 'probability_levels' => json_encode([ + ['value' => 1, 'label' => 'Rare', 'description' => 'Moins d\'une fois tous les 10 ans'], + ['value' => 2, 'label' => 'Peu probable', 'description' => 'Tous les 5 à 10 ans'], + ['value' => 3, 'label' => 'Possible', 'description' => 'Tous les 1 à 5 ans'], + ['value' => 4, 'label' => 'Probable', 'description' => 'Plusieurs fois par an'], + ['value' => 5, 'label' => 'Très probable', 'description' => 'Plusieurs fois par mois'], + ]), + + 'impact_levels' => json_encode([ + ['value' => 1, 'label' => 'Négligeable', 'description' => 'Aucun impact opérationnel mesurable'], + ['value' => 2, 'label' => 'Faible', 'description' => 'Impact limité, facilement résorbé'], + ['value' => 3, 'label' => 'Modéré', 'description' => 'Perturbation significative, récupérable'], + ['value' => 4, 'label' => 'Élevé', 'description' => 'Impact majeur sur les opérations'], + ['value' => 5, 'label' => 'Critique', 'description' => 'Menace existentielle pour l\'organisation'], + ]), + + 'exposure_levels' => null, + 'vulnerability_levels' => null, + + 'risk_thresholds' => json_encode([ + ['level' => 'low', 'label' => 'Faible', 'max' => 4, 'color' => 'success'], + ['level' => 'medium', 'label' => 'Moyen', 'max' => 9, 'color' => 'warning'], + ['level' => 'high', 'label' => 'Élevé', 'max' => 16, 'color' => 'danger'], + ['level' => 'critical', 'label' => 'Critique', 'max' => null, 'color' => 'alert'], + ]), + + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function down(): void + { + Schema::dropIfExists('risk_scoring_configs'); + } +}; diff --git a/database/seeders/DomainSeeder.php b/database/seeders/DomainSeeder.php index 2b6a7d11..97526ddb 100644 --- a/database/seeders/DomainSeeder.php +++ b/database/seeders/DomainSeeder.php @@ -14,36 +14,43 @@ class DomainSeeder extends Seeder * * @return void */ - public function run() + public function run() : void { - DB::table('domains')->delete(); + try { + DB::statement('SET FOREIGN_KEY_CHECKS=0;'); + DB::table('domains')->delete(); + } finally { + DB::statement('SET FOREIGN_KEY_CHECKS=1;'); + } - // get language $lang = getenv('LANG') ?: config('app.locale', 'en'); $lang = strtolower(substr($lang, 0, 2)); - // get filename - if ($lang === 'fr') - $filename="database/data/domains.fr.csv"; - else - $filename="database/data/domains.en.csv"; + $filename = $lang === 'fr' + ? 'database/data/domains.fr.csv' + : 'database/data/domains.en.csv'; - // Open CSV file - $csvFile = fopen(base_path($filename), "r"); + $csvFile = fopen(base_path($filename), 'r'); + if ($csvFile === false) { + throw new \RuntimeException("Cannot open seed file: {$filename}"); + } - // Loop on each line $firstline = true; - while (($data = fgetcsv($csvFile, 2000, ",")) !== FALSE) { - if (!$firstline) { - DB::table('domains')->insert([ - 'id' => (int) $data[0], - 'title' => $data[1], - 'description' => $data[2], - 'created_at' => now(), - ]); + try { + while (($data = fgetcsv($csvFile, 2000, ',')) !== false) { + if (!$firstline) { + DB::table('domains')->insert([ + 'id' => (int) $data[0], + 'title' => $data[1], + 'framework' => $data[2] ?? null, // à ajuster selon structure CSV + 'description' => $data[3] ?? $data[2], + 'created_at' => now(), + ]); + } + $firstline = false; } - $firstline = false; + } finally { + fclose($csvFile); } - fclose($csvFile); } } diff --git a/database/seeders/MeasureSeeder.php b/database/seeders/MeasureSeeder.php index f06827b9..5f88e595 100644 --- a/database/seeders/MeasureSeeder.php +++ b/database/seeders/MeasureSeeder.php @@ -16,40 +16,48 @@ class MeasureSeeder extends Seeder */ public function run() { - DB::table('measures')->delete(); + try { + DB::statement('SET FOREIGN_KEY_CHECKS=0;'); + DB::table('control_measure')->delete(); + DB::table('controls')->delete(); + DB::table('measures')->delete(); + } finally { + DB::statement('SET FOREIGN_KEY_CHECKS=1;'); + } - // get language - $lang = getenv('LANG'); + $lang = getenv('LANG') ?: config('app.locale', 'en'); + $lang = strtolower(substr($lang, 0, 2)); - // get filename - if (strtolower($lang)==="fr") - $filename="database/data/measures.fr.csv"; - else - $filename="database/data/measures.en.csv"; + $filename = $lang === 'fr' + ? 'database/data/measures.fr.csv' + : 'database/data/measures.en.csv'; - // Open CSV file - $csvFile = fopen(base_path($filename), "r"); + $csvFile = fopen(base_path($filename), 'r'); + if ($csvFile === false) { + throw new \RuntimeException("Cannot open seed file: {$filename}"); + } - // Loop on each line $firstline = true; - while (($data = fgetcsv($csvFile, 8000, ",")) !== FALSE) { - \Log::Debug($data); - if (!$firstline) { - Measure::create([ - "domain_id" => $data[0], - "clause" => $data[1], - "name" => $data[2], - "objective" => str_replace("\\n","\n",$data[3]), - "attributes" => str_replace("\\n","\n",$data[4]), - "input" => str_replace("\\n","\n",$data[5]), - "model" => str_replace("\\n","\n",$data[6]), - "indicator" => str_replace("\\n","\n",$data[7]), - "action_plan" => str_replace("\\n","\n",$data[8]), - "created_at" => now() - ]); + try { + while (($data = fgetcsv($csvFile, 8000, ',')) !== false) { + if (!$firstline) { + Measure::create([ + 'domain_id' => $data[0], + 'clause' => $data[1], + 'name' => $data[2], + 'objective' => str_replace('\\n', "\n", $data[3]), + 'attributes' => str_replace('\\n', "\n", $data[4]), + 'input' => str_replace('\\n', "\n", $data[5]), + 'model' => str_replace('\\n', "\n", $data[6]), + 'indicator' => str_replace('\\n', "\n", $data[7]), + 'action_plan' => str_replace('\\n', "\n", $data[8]), + 'created_at' => now(), + ]); + } + $firstline = false; } - $firstline = false; + } finally { + fclose($csvFile); } - fclose($csvFile); } } diff --git a/docker/deming.conf b/docker/deming.conf index 100fb4f2..09c6f88e 100644 --- a/docker/deming.conf +++ b/docker/deming.conf @@ -1,13 +1,11 @@ server { - listen 0.0.0.0; + listen 0.0.0.0:80; server_name _; - location / { proxy_redirect off; proxy_buffering off; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - proxy_pass http://127.0.0.1:8000; } -} \ No newline at end of file +} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 192b5613..65c13f85 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -9,13 +9,17 @@ services: ### PLEASE DISABLE FOR PRODUCTION - USE_DEMO_DATA=1 ### PLEASE DISABLE FOR PRODUCTION - #- RESET_DB=FR #EN OR FR ### PLEASE AFTER ONE RUN DISABLE FOR OPTIMIZATION - UPLOAD_DB_ISO27001=FR #EN OR FR + - APP_BANNER_TEST=false ### PLEASE AFTER ONE RUN DISABLE FOR OPTIMIZATION - INITIAL_DB=FR #EN OR FR - TZ=Europe/Paris - - APP_ENV=production + - DB_HOST=mysql + - APP_ENV=local + - DB_DATABASE=deming + - DB_USERNAME=deming_user + - DB_PASSWORD=demPasssword-123 - APP_FORCE_HTTPS=false volumes: - .env:/var/www/deming/.env @@ -23,24 +27,27 @@ services: - ./docker/custom/Kernel.php:/var/www/deming/app/Console/Kernel.php - ./docker/custom/app.php:/var/www/deming/config/app.php ports: - - 80:8000 + - 8000:8000 depends_on: mysql: condition: service_healthy mysql: - image: mysql:9 + image: mysql:9.5 environment: - MYSQL_DATABASE: '${DB_DATABASE}' - MYSQL_USER: '${DB_USERNAME}' - MYSQL_PASSWORD: '${DB_PASSWORD}' - MYSQL_ROOT_PASSWORD: '${DB_ROOTPASSWORD}' + MYSQL_DATABASE: deming + MYSQL_USER: deming_user + MYSQL_PASSWORD: demPasssword-123 + MYSQL_RANDOM_ROOT_PASSWORD: "1" expose: - 3306 healthcheck: - test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -p$$MYSQL_ROOT_PASSWORD" ] - interval: 10s + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "--silent"] + interval: 5s timeout: 5s - retries: 3 + retries: 10 + start_period: 30s + + ### PLEASE ENABLE FOR PERSISTENT DATABASE DATA volumes: - dbdata:/var/lib/mysql diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index d949f73e..16ceecc0 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,16 +1,46 @@ #!/usr/bin/env bash +set -e cd /var/www/deming + +DB_H="mysql" + +echo "Waiting for MySQL to be ready..." +until mysqladmin ping -h"${DB_H}" --silent 2>/dev/null; do + echo " Not ready, retrying in 3s..." + sleep 3 +done +echo "MySQL is ready." + +# APP_KEY — générer seulement si absent +grep -q '^APP_KEY=base64:' .env || php artisan key:generate --no-interaction + +# Initialisation DB bash /etc/resetdb.sh bash /etc/initialdb.sh -php artisan storage:link -bash /etc/uploadiso27001db.sh -bash /etc/userdemo.sh -php artisan passport:install --force --quiet -php artisan key:generate -chown www-data:www-data storage/oauth-*.key -chmod 600 storage/oauth-*.key + +# Storage +php artisan storage:link --quiet + +# Import référentiel et données de démo +bash /etc/uploadiso27001db.sh || echo "uploadiso27001db skipped" +bash /etc/userdemo.sh || echo "userdemo skipped" + +# Passport (OAuth) +php artisan passport:install --force || echo "Passport skipped" +if ls storage/oauth-*.key 2>/dev/null; then + chown www-data:www-data storage/oauth-*.key + chmod 600 storage/oauth-*.key +fi + +# Services système +service cron start || true + +# Copier le vhost nginx +rm -f /etc/nginx/sites-enabled/default + +# Démarrer artisan serve en arrière-plan (port 8000 — cible du reverse proxy nginx) php artisan serve --host 0.0.0.0 --port 8000 & -service postfix start -service cron start -nginx -g "daemon off;" + +# Nginx en PID 1 +exec nginx -g "daemon off;" diff --git a/docker/initialdb.sh b/docker/initialdb.sh index a641565c..6801e568 100644 --- a/docker/initialdb.sh +++ b/docker/initialdb.sh @@ -1,36 +1,15 @@ #!/bin/bash -# Définit un délai de sommeil par défaut de 10 secondes -DEFAULT_SLEEP=1 +echo "Initialize database" -# Vérifie si la variable d'environnement RESET_DB_SLEEP est définie -if [ -n "${DB_SLEEP}" ]; then - # Utilise la valeur définie par l'utilisateur - SLEEP_TIME="${DB_SLEEP}" -else - # Utilise la valeur par défaut - SLEEP_TIME="${DEFAULT_SLEEP}" -fi - -# Affiche le message -echo "Waiting for ${SLEEP_TIME} seconds before executing migration..." -# Attend le nombre de secondes spécifié -sleep "${SLEEP_TIME}" +php artisan migrate -# Vérifie si la variable d'environnement est égale à 1 if [ "${INITIAL_DB}" = "EN" ]; then - # Se déplace vers le répertoire /var/www/deming/ - cd /var/www/deming/ - # Exécute la commande - php artisan migrate --seed --force - # Exit avec le code 0 pour indiquer que le script s'est terminé avec succès - exit 0 -fi -if [ "${INITIAL_DB}" = "FR" ]; then - # Se déplace vers le répertoire /var/www/deming/ - cd /var/www/deming/ - # Exécute la commande - LANG=fr php artisan migrate --seed --force - # Exit avec le code 0 pour indiquer que le script s'est terminé avec succès - exit 0 + php artisan db:seed --class=DatabaseSeeder +elif [ "${INITIAL_DB}" = "FR" ]; then + LANG=fr php artisan db:seed --class=DatabaseSeeder +else + echo "WARNING: INITIAL_DB='${INITIAL_DB}' non reconnu (EN ou FR attendu)." + exit 1 fi + diff --git a/docs/config.fr.md b/docs/config.fr.md index c43ae5c0..cb61d22e 100644 --- a/docs/config.fr.md +++ b/docs/config.fr.md @@ -1,13 +1,13 @@ ## Configuration -### Attributs +### Attributs {#tags} Cet écran permet de gérer les attributs associés aux mesures de sécurité. Il contient la liste des attributs et permet de créer, supprimer ou modifier des listes d’attributs. [![Screenshot](images/tags.fr.png)](images/tags.fr.png) -### Domaines +### Domaines {#domains} Cet écran permet de créer, modifier ou supprimer des listes de domaines de sécurité. @@ -15,7 +15,7 @@ Cet écran permet de créer, modifier ou supprimer des listes de domaines de sé L’application est fournie avec une base de mesures de sécurité inspirée de la norme ISO 27001:2022, mais il est possible de définir de nouveaux domaines de sécurité inspirés d’autres normes comme PCI DSS, HDS... -### Utilisateurs +### Utilisateurs {#users} Les utilisateurs sont définis dans l’application. @@ -31,7 +31,7 @@ Il existe quatre rôles différents : * Auditeur : l’auditeur a un accès en lecture à l’ensemble des informations de l’application. -### Groupes +### Groupes {#groups} Cet écran permet de définir des groupes d'utilisateurs. Un groupe permet de rassembler un ensemble d'utilisateurs et de contrôles. @@ -45,7 +45,7 @@ Un groupe est composé : * d'une liste d'utilisateurs * d'une listes de contrôles -### Rapports +### Rapports {#reports} L’application permet de générer le rapport de pilotage du SMSI et d’exporter dans un fichier Excel la liste des domaines, les mesures de sécurités et tous les contrôles réalisés. @@ -57,7 +57,7 @@ Voici le rapport de pilotage du SMSI : [![Screenshot](images/report2.png){: style="width:600px"}](images/report2.png) -### Importation +### Importation {#import} Il est possible d'importer des mesures de sécurité depuis un fichier .XLSX ou depuis la base de données de modèles. @@ -65,7 +65,7 @@ Lors de l'importation, il est possible de supprimer tous les autres contrôles e [![Screenshot](images/import.png){: style="width:600px"}](images/import.png) -### Documents +### Documents {#documents} Cet écran permet de modifier la configuration de la gestion des document utilisés dans Deming. diff --git a/docs/controls.fr.md b/docs/controls.fr.md index 8784fd00..6a0898b9 100644 --- a/docs/controls.fr.md +++ b/docs/controls.fr.md @@ -1,8 +1,8 @@ ## Contrôles -### Liste des contrôles +### Liste des contrôles {#list} -Cet écran permet d’afficher la liste des contrôles et de les filtrer par : +Cet écran permet d'afficher la liste des contrôles et de les filtrer par : * Domaine, @@ -22,15 +22,15 @@ Lorsque vous cliquez sur : * La clause, vous arrivez à l'écran d'[affichage de la mesure de sécurité](measures.fr.md/#show). -* La date de réalisation, de planification ou la date du contrôle suivant, vous arrivez l'écran d'[affichage du contrôle de sécurité](#show). +* La date de réalisation, de planification ou la date du contrôle suivant, vous arrivez l'écran d'[affichage du contrôle de sécurité](controls.fr.md/#show). -### Afficher un contrôle +### Afficher un contrôle {#show} -Cet écran contient les informations d’un contrôle : +Cet écran contient les informations d'un contrôle : * Le nom du contrôle ; -* L’objectif du contrôle ; +* L'objectif du contrôle ; * Les attributs ; @@ -44,33 +44,87 @@ Cet écran contient les informations d’un contrôle : * Le score attribué au contrôle (vert, orange ou rouge). -Les boutons « Faire » et « Planifier » sont présents si ce contrôle n’a pas encore été réalisé. +Les boutons « Faire » et « Planifier » sont présents si ce contrôle n'a pas encore été réalisé. Les boutons « Modifier » et « Supprimer » sont présents si l'utilisateur est administrateur. - [![Screenshot](images/c2.fr.png)](images/c2.fr.png) +[![Screenshot](images/c2.fr.png)](images/c2.fr.png) Lorsque vous cliquez sur : -* « Faire », vous êtes envoyé vers l’[écran de réalisation d’un contrôle](#make) +* « Faire », vous êtes envoyé vers l'[écran de réalisation d'un contrôle](#make) -* « Planifier », vous êtes envoyé vers l’[écran de planification d’un contrôle](#plan) +* « Planifier », vous êtes envoyé vers l'[écran de planification d'un contrôle](#plan) -* « Modifier », vous êtes envoyé vers l’[écran de modification du contrôle](#edit) +* « Modifier », vous êtes envoyé vers l'[écran de modification du contrôle](#edit) * « Supprimer », le contrôle est supprimé et vous êtes envoyé vers la [liste des contrôles](#list) * « Annuler », vous êtes envoyé vers la [liste des contrôles](#list) -### Planifier un contrôle +### Créer un contrôle {#create} + +Un contrôle peut être créé de deux façons : + +* Sur base d'une mesure de sécurité : depuis l'[écran d'affichage de la mesure](measures.fr.md/#show), le bouton « Créer un contrôle » génère un nouveau contrôle pré-rempli avec le nom et l'objectif de la mesure. + +* Manuellement : depuis la [liste des contrôles](#list), le bouton « Nouveau » ouvre le formulaire de création. + +Cet écran contient les champs suivants : + +* La ou les clauses associées ; + +* Le nom du contrôle ; + +* L'objectif du contrôle ; + +* Les attributs ; + +* Les données à collecter ; + +* Le modèle de calcul du score. + +Lorsque vous cliquez sur : + +* « Créer », le contrôle est enregistré et vous êtes renvoyé vers l'[écran d'affichage du contrôle](#show) + +* « Annuler », vous êtes renvoyé vers la [liste des contrôles](#list) + +### Modifier un contrôle {#edit} + +Cet écran permet de modifier les informations d'un contrôle existant. + +Il est accessible uniquement aux utilisateurs disposant du rôle **Administrateur**. + +Cet écran contient les mêmes champs que l'[écran de création](#create) : + +* La ou les clauses associées ; + +* Le nom du contrôle ; + +* L'objectif du contrôle ; + +* Les attributs ; + +* Les données à collecter ; + +* Le modèle de calcul du score. + +Lorsque vous cliquez sur : + +* « Modifier », les modifications sont enregistrées et vous êtes renvoyé vers l'[écran d'affichage du contrôle](#show) + +* « Annuler », vous êtes renvoyé vers l'[écran d'affichage du contrôle](#show) + +### Planifier un contrôle {#plan} Cet écran permet de planifier un contrôle. -Cet écran contient les informations d’un contrôle : +Cet écran contient les informations d'un contrôle : * Le nom du contrôle ; -* L’objectif du contrôle ; +* L'objectif du contrôle ; * La date de planification ; @@ -82,12 +136,12 @@ Cet écran contient les informations d’un contrôle : Lorsque vous cliquez sur : -* « Plan », la date de planification, la récurrence et les responsables sont mis à jour et vous êtes renvoyés vers l’[écran d'affichage du contrôle](#show) +* « Plan », la date de planification, la récurrence et les responsables sont mis à jour et vous êtes renvoyés vers l'[écran d'affichage du contrôle](controls.fr.md/#show) -* « Annuler », vous êtes renvoyés vers l’[écran d'affichage du contrôle](#show) +* « Annuler », vous êtes renvoyés vers l'[écran d'affichage du contrôle](controls.fr.md/#show) -### Réaliser un contrôle +### Réaliser un contrôle {#make} Cet écran permet de réaliser un contrôle de sécurité. @@ -97,7 +151,7 @@ Cet écran contient : * Le nom du contrôle, -* L’objectif, +* L'objectif, * Les données, @@ -115,7 +169,7 @@ Cet écran contient : * Le score, -* Le plan d’action, +* Le plan d'action, * La date du prochaine contrôle @@ -128,12 +182,10 @@ Lorsque vous cliquez sur : * « Sauver », le contrôle est sauvé - et vous revenez vers la [liste des contrôles](#list). -### Fiche de contrôle - +### Fiche de contrôle {#sheet} La fiche de contrôle est un document Word généré par l'application sur base des données du contrôle. diff --git a/docs/controls.md b/docs/controls.md index 9565a6d9..b8389611 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -1,6 +1,6 @@ ## Measurements -### List of measurements +### List of measurements {#list} This screen is used to display the list of measurements and to filter them by: @@ -24,7 +24,7 @@ When you click: * The date of completion, planning or the date of the next check, you arrive at the [security check display](#show) screen. -### Show a measurement +### Show a measurement {#show} This screen contains the information of a measurement: @@ -62,7 +62,7 @@ When you click: * "Cancel", you are sent to the [list of measurements](#list) -### Schedule a measurement +### Schedule a measurement {#plan} This screen is used to schedule a measurement. @@ -89,7 +89,7 @@ When you click: * "Cancel", You are returned to the [measurement display screen](#show) -### Making a measurement check +### Making a measurement {#make} This screen allows you to perform a measurement. @@ -133,8 +133,7 @@ When you click: and you return to the [list of measurements](#list). - -### Measurement sheet +### Measurement sheet {#sheet} The measurement sheet is a word document generated by the application based on the measurement data. diff --git a/docs/css/extra.css b/docs/css/extra.css deleted file mode 100644 index 6fe3f97e..00000000 --- a/docs/css/extra.css +++ /dev/null @@ -1,4 +0,0 @@ - -table td { - white-space: normal !important; -} diff --git a/docs/css/overrides.css b/docs/css/overrides.css new file mode 100644 index 00000000..aff638cf --- /dev/null +++ b/docs/css/overrides.css @@ -0,0 +1,29 @@ +/* Limite la largeur du contenu principal +/* .wy-nav-content { */ +/* max-width: 1200px; */ /* Ajuste selon ton besoin */ +/* mmargin: 0 auto; */ /* Centre le contenu */ +/* } + +/* Change la couleur inline en bleue */ +.md-typeset code { + color: #0066cc; +} +/* +.img-600 { + width: 600px !important; +} +.img-300 { + width: 300px !important; + max-width: none !important; +} +*/ +/* +body { + border: 5px solid red; +} + +.img-test { + border: 5px solid green !important; +} +*/ + diff --git a/docs/images/douments.fr.png b/docs/images/douments.fr.png new file mode 100644 index 00000000..0f48e496 Binary files /dev/null and b/docs/images/douments.fr.png differ diff --git a/docs/images/groups.fr.png b/docs/images/groups.fr.png new file mode 100644 index 00000000..67ba95b9 Binary files /dev/null and b/docs/images/groups.fr.png differ diff --git a/docs/images/groups.png b/docs/images/groups.png new file mode 100644 index 00000000..b053cdb9 Binary files /dev/null and b/docs/images/groups.png differ diff --git a/docs/images/home1.fr.png b/docs/images/home1.fr.png index 6a9e888d..9ef4bc62 100644 Binary files a/docs/images/home1.fr.png and b/docs/images/home1.fr.png differ diff --git a/docs/images/home1.png b/docs/images/home1.png index 7850d33b..77d4d5f2 100644 Binary files a/docs/images/home1.png and b/docs/images/home1.png differ diff --git a/docs/images/risk.edit.fr.png b/docs/images/risk.edit.fr.png new file mode 100644 index 00000000..dd208862 Binary files /dev/null and b/docs/images/risk.edit.fr.png differ diff --git a/docs/images/risk.edit.png b/docs/images/risk.edit.png new file mode 100644 index 00000000..3e7ce3fe Binary files /dev/null and b/docs/images/risk.edit.png differ diff --git a/docs/images/risk.fr.png b/docs/images/risk.fr.png new file mode 100644 index 00000000..46ef6280 Binary files /dev/null and b/docs/images/risk.fr.png differ diff --git a/docs/images/risk.list.fr.png b/docs/images/risk.list.fr.png new file mode 100644 index 00000000..90a8405c Binary files /dev/null and b/docs/images/risk.list.fr.png differ diff --git a/docs/images/risk.list.png b/docs/images/risk.list.png new file mode 100644 index 00000000..b23217aa Binary files /dev/null and b/docs/images/risk.list.png differ diff --git a/docs/images/risk.matrix.fr.png b/docs/images/risk.matrix.fr.png new file mode 100644 index 00000000..ca302673 Binary files /dev/null and b/docs/images/risk.matrix.fr.png differ diff --git a/docs/images/risk.matrix.png b/docs/images/risk.matrix.png new file mode 100644 index 00000000..4f8c023f Binary files /dev/null and b/docs/images/risk.matrix.png differ diff --git a/docs/images/risk.png b/docs/images/risk.png new file mode 100644 index 00000000..225ceae5 Binary files /dev/null and b/docs/images/risk.png differ diff --git a/docs/images/risk.scoring.create.fr.png b/docs/images/risk.scoring.create.fr.png new file mode 100644 index 00000000..822c53f3 Binary files /dev/null and b/docs/images/risk.scoring.create.fr.png differ diff --git a/docs/images/risk.scoring.create.png b/docs/images/risk.scoring.create.png new file mode 100644 index 00000000..8d9a32df Binary files /dev/null and b/docs/images/risk.scoring.create.png differ diff --git a/docs/images/risk.scoring.fr.png b/docs/images/risk.scoring.fr.png new file mode 100644 index 00000000..fc9d1a61 Binary files /dev/null and b/docs/images/risk.scoring.fr.png differ diff --git a/docs/images/risk.scoring.png b/docs/images/risk.scoring.png new file mode 100644 index 00000000..d9c39d81 Binary files /dev/null and b/docs/images/risk.scoring.png differ diff --git a/docs/images/risk.show.fr.png b/docs/images/risk.show.fr.png new file mode 100644 index 00000000..8448e399 Binary files /dev/null and b/docs/images/risk.show.fr.png differ diff --git a/docs/images/risk.show.png b/docs/images/risk.show.png new file mode 100644 index 00000000..0dc999f3 Binary files /dev/null and b/docs/images/risk.show.png differ diff --git a/docs/index.fr.md b/docs/index.fr.md index 3ca17d2b..21b27fdf 100644 --- a/docs/index.fr.md +++ b/docs/index.fr.md @@ -9,7 +9,7 @@ Deming est un outil Open Source conçu pour aider les RSSI à maintenir leur sys Deming offre des fonctionnalités telles que la [gestion des mesures de sécurité](measures.fr.md), la [planification des contrôles](plan.fr.md), la création des [fiches de contrôle](controls.fr.md/#sheet), l’enregistrement des preuves, le suivi [des plans d’action](actions.fr.md) ainsi que des [tableaux de bord](dashboards.fr.md) et -des [rapports de pilotage du SMSI](config.fr.md/#report) pour aider les RSSI à suivre le maintien des mesures de sécurité de l'information. +des [rapports de pilotage du SMSI](config.fr.md/#reports) pour aider les RSSI à suivre le maintien des mesures de sécurité de l'information. L'application est conçue pour être compatible avec la norme ISO 27001:2022, en suivant les exigences spécifiques de la norme pour la planification, la mise en œuvre, la vérification et l'amélioration continue du système de management de la sécurité de l'information. diff --git a/docs/measures.fr.md b/docs/measures.fr.md index f59525f8..784a184c 100644 --- a/docs/measures.fr.md +++ b/docs/measures.fr.md @@ -10,7 +10,7 @@ La liste des mesures de sécurité permet d’afficher la liste des mesures, de En cliquant sur : -* le domaine, vous arrivez sur la [définition du domaine choisi](config.fr.md/#domain) +* le domaine, vous arrivez sur la [définition du domaine choisi](config.fr.md/#domains) * la clause, vous arrivez sur la [description de la mesure de sécurité](#show) diff --git a/docs/risks.fr.md b/docs/risks.fr.md new file mode 100644 index 00000000..af870bc3 --- /dev/null +++ b/docs/risks.fr.md @@ -0,0 +1,231 @@ +# Registre des risques + +Le registre des risques permet de gérer les risques de sécurité de l'information conformément aux exigences de la norme ISO 27001:2022, notamment : + +- **§ 6.1.2** — Processus d'appréciation des risques de sécurité de l'information +- **§ 6.1.3** — Processus de traitement des risques de sécurité de l'information +- **§ 8.2** — Appréciation des risques de sécurité de l'information + +Chaque risque est évalué selon une méthode de scoring configurable, lié aux [contrôles](controls.fr.md) et [plans d'action](actions.fr.md) existants de l'application, et soumis à un cycle de revue périodique. + +## Matrice des risques {#matrix} + +Cet écran affiche une vue synthétique de l'ensemble des risques sous forme de matrice. + +[![Screenshot](images/risk.matrix.fr.png)](images/risk.matrix.fr.png) + + +La matrice croise les axes de probabilité (ou de vraisemblance) et d'impact. Chaque cellule est colorée selon le niveau de risque correspondant et affiche le nombre de risques positionnés dans cette cellule. En cliquant sur une cellule non vide, vous accédez à la [liste des risques](#list) filtrée sur ce score. + +> Les axes de la matrice et les couleurs des cellules s'adaptent automatiquement à la [méthode de scoring active](#scoring). + +La partie latérale de l'écran résument la situation : + +À droite de la matrice, un tableau présente le **nombre de risques par niveau** et la **répartition des risques par statut de traitement**, avec pour chaque élément un lien direct vers la liste des risques filtrée sur le crit!re sélectionné. + +## Liste des risques {#list} + +Cet écran affiche l'ensemble des risques enregistrés. + +[![Screenshot](images/risk.list.fr.png)](images/risk.list.fr.png) + +La liste peut être filtrée par : + +- **Statut de traitement** — Non évalué, Non accepté, Accepté, Mitigé, etc. +- **Propriétaire** — la personne responsable de la revue du risque. +- **En retard** — affiche uniquement les risques dont la date de prochaine revue est dépassée. + +> Les utilisateurs avec le rôle **Audité** ne voient que les risques dont ils sont propriétaires. + +Pour chaque risque, la liste affiche : + +- Le nom du risque ; +- Le propriétaire ; +- La probabilité et l'impact ; +- Le **score calculé**, avec un badge coloré selon le niveau de risque (Faible, Moyen, Élevé, Critique) ; +- Le statut de traitement ; +- La date de prochaine revue, en rouge si dépassée. + +Les boutons **Nouveau** en haut à droite permettent de créer un nouveau risque. + +## Afficher un risque {#show} + +Cet écran affiche le détail d'un risque. + +[![Screenshot](images/risk.show.fr.png){: style="width:600px"}](images/risk.show.fr.png) + +Il contient : + +- Le **nom** et la **description** du risque ; +- Le **propriétaire** et la fréquence de revue avec la date de prochaine revue ; +- L'évaluation : + - La **probabilité** avec son libellé et son commentaire (formules standard) ; + - L'**exposition** et la **vulnérabilité** avec la vraisemblance calculée (formule BSI 200-3) ; + - L'**impact** avec son libellé et son commentaire ; + - Le **score** calculé et le **niveau de risque** ; +- Le traitement : + - Le **statut de traitement** avec son commentaire ; + - Les **contrôles liés** (si statut = Mitigé) ; + - Les **plans d'action liés** (si statut = Non accepté) ; + +Les boutons disponibles dépendent du rôle de l'utilisateur : + +- **Modifier** — accède à l'[écran de modification](#edit) (Administrateur et Utilisateur) ; +- **Supprimer** — supprime le risque après confirmation (Administrateur uniquement) ; +- **Historique** — affiche le journal des modifications (Administrateur uniquement) ; +- **Annuler** — revient à la [liste des risques](#list). + +## Créer un risque {#create} + +Cet écran permet de créer un nouveau risque. + +[![Screenshot](images/risk.edit.fr.png)](images/risk.edit.fr.png) + +Il contient les champs suivants : + +- **Nom** *(obligatoire)* — intitulé court et identifiable du risque ; +- **Description** — description détaillée du risque ; +- **Propriétaire** — utilisateur responsable de la revue périodique du risque ; +- **Fréquence de revue** — intervalle en mois entre deux revues. La date de prochaine revue est calculée automatiquement si elle n'est pas saisie manuellement. + +L'évaluation du risque dépend de la [méthode de scoring active](#scoring) : + +- Pour les formules standard (Probabilité × Impact, Probabilité + Impact, max) : + - **Probabilité** — niveau de 1 à N avec libellé et description ; + - **Commentaire probabilité** ; +- Pour la formule BSI 200-3 (Vraisemblance × Impact) : + - **Exposition** — accessibilité du système (ex. 0 = hors réseau, 1 = interne, 2 = Internet) ; + - **Vulnérabilité** — niveau d'exploitabilité des failles connues ; +- **Impact** — gravité des conséquences si le risque se matérialise ; +- **Commentaire impact** ; +- **Score calculé** — mis à jour en temps réel selon les valeurs saisies, avec badge coloré indiquant le niveau. + +Le traitement du risque se configure via : + +- **Statut** — parmi : Non évalué, Non accepté, Accepté temporairement, Accepté, Mitigé, Transféré, Évité ; +- **Commentaire statut** ; +- **Contrôles liés** — sélection multiple parmi les contrôles existants (affiché uniquement si statut = *Mitigé*) ; +- **Plans d'action liés** — sélection multiple parmi les plans d'action existants (affiché uniquement si statut = *Non accepté*). + +> Un avertissement est affiché si un risque *Mitigé* est sauvegardé sans contrôle lié, ou si un risque *Non accepté* est sauvegardé sans plan d'action lié. + +Lorsque vous cliquez sur : + +- **Sauver** — le risque est créé et vous êtes renvoyé vers l'[affichage du risque](#show) ; +- **Annuler** — vous revenez à la [liste des risques](#list). + +## Modifier un risque {#edit} + +Cet écran permet de modifier un risque existant. Il contient les mêmes champs que l'[écran de création](#create), avec en plus : + +- La **date de prochaine revue** modifiable manuellement ; +- Le score actuel pré-rempli, recalculé dynamiquement à chaque modification. + +Lorsque la fréquence de revue est modifiée et qu'aucune date de prochaine revue n'est saisie, celle-ci est recalculée automatiquement à partir de la date du jour. + +Lorsque vous cliquez sur : + +- **Sauver** — le risque est mis à jour et vous êtes renvoyé vers l'[affichage du risque](#show) ; +- **Annuler** — vous revenez à l'[affichage du risque](#show) sans modification. + +## Configuration du scoring {#scoring} + +Le scoring des risques est entièrement configurable. Plusieurs configurations peuvent être définies, mais une seule est active à la fois. Le changement de configuration active s'applique immédiatement à l'ensemble du registre. + +### Liste des configurations {#scoring-list} + +Cet écran affiche l'ensemble des configurations de scoring définies dans l'application. + +[![Screenshot](images/risk.scoring.fr.png)](images/risk.scoring.fr.png) + +Pour chaque configuration, la liste affiche : + +- Un indicateur de configuration **active** ; +- Le **nom** de la configuration ; +- La **formule** utilisée ; +- Le détail des **niveaux** configurés (probabilité ou exposition/vulnérabilité, impact) ; +- Les **seuils de classification** sous forme de badges colorés avec leur plage de score en info-bulle. + +Les boutons d'action disponibles pour chaque ligne sont : + +- **Modifier** (crayon) — accède à l'[écran de modification de la configuration](#scoring-edit) ; +- **Activer** (coche) — active cette configuration après confirmation. L'ancienne configuration active est automatiquement désactivée. Bouton absent si la configuration est déjà active ; +- **Supprimer** (flamme) — supprime la configuration après confirmation. Impossible de supprimer la configuration active. + +Le bouton **Nouveau** en haut à droite permet de créer une nouvelle configuration. + +### Créer ou modifier une configuration {#scoring-edit} + +Cet écran permet de définir une méthode de scoring complète. + +[![Screenshot](images/risk.scoring.create.fr.png)](images/risk.scoring.create.fr.png) + +#### Nom et formule + +- **Nom** — libellé de la configuration, affiché dans la liste ; +- **Formule** — la méthode de calcul du score. Quatre formules sont disponibles : + +| Formule | Calcul | Usage recommandé | +|---|---|---| +| Probabilité × Impact | Score = P × I | ISO 27005 / ISO 27001 classique | +| Vraisemblance × Impact | Score = (Exposition + Vulnérabilité) × I | BSI 200-3 / ISACA CSC-IT | +| Probabilité + Impact | Score = P + I | Triage rapide | +| max(Probabilité, Impact) | Score = max(P, I) | Approche conservatrice | + +> La sélection de la formule *Vraisemblance × Impact* affiche les sections **Exposition** et **Vulnérabilité** à la place de la section **Probabilité**. + +#### Niveaux + +Chaque axe d'évaluation dispose d'un tableau de niveaux personnalisables. Chaque niveau est défini par : + +- **Valeur** — entier unique utilisé dans le calcul du score ; +- **Libellé** — nom court affiché dans le formulaire de risque ; +- **Description** — texte explicatif pour guider les utilisateurs lors de l'évaluation. + +Le bouton **Ajouter un niveau** permet d'insérer une ligne supplémentaire. Le bouton de suppression (corbeille) retire la ligne. Un minimum de deux niveaux est requis par axe. + +Les axes configurables sont : + +- **Probabilité** — présent pour les formules standard ; +- **Exposition** — présent pour la formule BSI (0 = hors réseau, 1 = réseau interne, 2 = exposé Internet) ; +- **Vulnérabilité** — présent pour la formule BSI (1 = aucune connue, 2 = connue non exploitable, 3 = exploitable en interne, 4 = exploitable à distance) ; +- **Impact** — présent pour toutes les formules. + +#### Seuils de classification + +Les seuils définissent la correspondance entre un score numérique et un niveau de risque qualifié. Chaque seuil comprend : + +- **Clé interne** — identifiant technique (ex. `low`, `medium`, `high`, `critical`) ; +- **Libellé** — nom affiché dans les badges et les rapports ; +- **Score max** — borne supérieure du seuil. Le dernier seuil n'a pas de borne supérieure (attrape-tout) ; +- **Couleur** — couleur du badge MetroUI : Vert, Orange, Rouge, Rouge foncé, Bleu, Gris. + +La colonne **Aperçu** affiche un badge en temps réel reflétant la couleur et le libellé saisis. + +> Trier les seuils du score le plus bas au plus élevé. Le dernier seuil doit toujours avoir le champ Score max vide. + +Lorsque vous cliquez sur : + +- **Sauver** — la configuration est enregistrée (inactive par défaut à la création) et vous revenez à la [liste des configurations](#scoring-list) ; +- **Annuler** — vous revenez à la [liste des configurations](#scoring-list) sans modification. + +--- + +## Intégration avec les autres modules + +Le registre des risques s'intègre avec les modules existants de Deming : + +- **Contrôles** — un risque avec le statut *Mitigé* peut être lié à un ou plusieurs contrôles de sécurité. Le lien est bidirectionnel : la page de détail du risque liste les contrôles associés et permet d'y accéder directement. + +- **Plans d'action** — un risque avec le statut *Non accepté* doit être associé à un ou plusieurs plans d'action. La page de détail du risque liste les plans d'action associés et permet d'y accéder directement. + +- **Planning** — la date de prochaine revue de chaque risque est gérée indépendamment du planning des contrôles, mais peut être alignée manuellement selon les cycles de revue de votre SMSI. + +- **Rôles** — les restrictions d'accès suivent le même modèle que le reste de l'application : + +| Rôle | Accès | +|---|---| +| Administrateur | Lecture, création, modification, suppression, configuration du scoring | +| Utilisateur | Lecture, création, modification | +| Auditeur | Lecture seule (tous les risques) | +| Audité | Lecture et modification des risques dont il est propriétaire uniquement | diff --git a/docs/risks.md b/docs/risks.md new file mode 100644 index 00000000..ae98a82d --- /dev/null +++ b/docs/risks.md @@ -0,0 +1,230 @@ +# Risk Register + +The risk register allows you to manage information security risks in accordance with the requirements of ISO 27001:2022, specifically: + +- **§ 6.1.2** — Information security risk assessment process +- **§ 6.1.3** — Information security risk treatment process +- **§ 8.2** — Information security risk assessment + +Each risk is evaluated using a configurable scoring method, linked to the application's existing [controls](controls.md) and [action plans](actions.md), and subject to a periodic review cycle. + +## Risk Matrix {#matrix} + +This screen displays a summary view of all risks in matrix form. + +[![Screenshot](images/risk.matrix.png)](images/risk.matrix.png) + +The matrix crosses the probability (or likelihood) and impact axes. Each cell is color-coded according to the corresponding risk level and displays the number of risks positioned in that cell. Clicking on a non-empty cell takes you to the [risk list](#list) filtered on that score. + +> The matrix axes and cell colors automatically adapt to the [active scoring method](#scoring). + +The side panel summarises the situation: + +To the right of the matrix, a table shows the **number of risks per level** and the **distribution of risks by treatment status**, each with a direct link to the risk list filtered on the selected criterion. + +## Risk List {#list} + +This screen displays all recorded risks. + +[![Screenshot](images/risk.list.png)](images/risk.list.png) + +The list can be filtered by: + +- **Treatment status** — Unassessed, Not accepted, Accepted, Mitigated, etc. +- **Owner** — the person responsible for reviewing the risk. +- **Overdue** — displays only risks whose next review date has passed. + +> Users with the **Auditee** role only see risks they own. + +For each risk, the list displays: + +- The risk name; +- The owner; +- The probability and impact; +- The **calculated score**, with a color-coded badge indicating the risk level (Low, Medium, High, Critical); +- The treatment status; +- The next review date, shown in red if overdue. + +The **New** button in the top right allows you to create a new risk. + +## View a Risk {#show} + +This screen displays the detail of a risk. + +[![Screenshot](images/risk.show.png){: style="width:600px"}](images/risk.show.png) + +It contains: + +- The risk **name** and **description**; +- The **owner** and review frequency with the next review date; +- The assessment: + - **Probability** with its label and comment (standard formulas); + - **Exposure** and **vulnerability** with the calculated likelihood (BSI 200-3 formula); + - **Impact** with its label and comment; + - The calculated **score** and the **risk level**; +- The treatment: + - The **treatment status** with its comment; + - **Linked controls** (if status = Mitigated); + - **Linked action plans** (if status = Not accepted); + +The available buttons depend on the user's role: + +- **Edit** — accesses the [edit screen](#edit) (Administrator and User); +- **Delete** — deletes the risk after confirmation (Administrator only); +- **History** — displays the change log (Administrator only); +- **Cancel** — returns to the [risk list](#list). + +## Create a Risk {#create} + +This screen allows you to create a new risk. + +[![Screenshot](images/risk.edit.png)](images/risk.edit.png) + +It contains the following fields: + +- **Name** *(required)* — short, identifiable label for the risk; +- **Description** — detailed description of the risk; +- **Owner** — user responsible for the periodic review of the risk; +- **Review frequency** — interval in months between two reviews. The next review date is calculated automatically if not entered manually. + +The risk assessment depends on the [active scoring method](#scoring): + +- For standard formulas (Probability × Impact, Probability + Impact, max): + - **Probability** — level from 1 to N with label and description; + - **Probability comment**; +- For the BSI 200-3 formula (Likelihood × Impact): + - **Exposure** — system accessibility (e.g. 0 = off-network, 1 = internal, 2 = Internet); + - **Vulnerability** — level of exploitability of known weaknesses; +- **Impact** — severity of consequences if the risk materialises; +- **Impact comment**; +- **Calculated score** — updated in real time based on the entered values, with a color-coded badge indicating the level. + +Risk treatment is configured via: + +- **Status** — one of: Unassessed, Not accepted, Temporarily accepted, Accepted, Mitigated, Transferred, Avoided; +- **Status comment**; +- **Linked controls** — multiple selection from existing controls (displayed only if status = *Mitigated*); +- **Linked action plans** — multiple selection from existing action plans (displayed only if status = *Not accepted*). + +> A warning is displayed if a *Mitigated* risk is saved without a linked control, or if a *Not accepted* risk is saved without a linked action plan. + +When you click: + +- **Save** — the risk is created and you are redirected to the [risk view](#show); +- **Cancel** — you return to the [risk list](#list). + +## Edit a Risk {#edit} + +This screen allows you to modify an existing risk. It contains the same fields as the [create screen](#create), with the addition of: + +- The **next review date**, editable manually; +- The current score pre-filled, dynamically recalculated with each change. + +When the review frequency is changed and no next review date is entered, it is automatically recalculated from today's date. + +When you click: + +- **Save** — the risk is updated and you are redirected to the [risk view](#show); +- **Cancel** — you return to the [risk view](#show) without changes. + +## Scoring Configuration {#scoring} + +Risk scoring is fully configurable. Multiple configurations can be defined, but only one is active at a time. Changing the active configuration takes effect immediately across the entire register. + +### Configuration List {#scoring-list} + +This screen displays all scoring configurations defined in the application. + +[![Screenshot](images/risk.scoring.png)](images/risk.scoring.png) + +For each configuration, the list displays: + +- An **active** configuration indicator; +- The configuration **name**; +- The **formula** used; +- The detail of the configured **levels** (probability or exposure/vulnerability, impact); +- The **classification thresholds** as color-coded badges with their score range shown in a tooltip. + +The available action buttons for each row are: + +- **Edit** (pencil) — accesses the [configuration edit screen](#scoring-edit); +- **Activate** (checkmark) — activates this configuration after confirmation. The previously active configuration is automatically deactivated. Button absent if the configuration is already active; +- **Delete** (flame) — deletes the configuration after confirmation. The active configuration cannot be deleted. + +The **New** button in the top right allows you to create a new configuration. + +### Create or Edit a Configuration {#scoring-edit} + +This screen allows you to define a complete scoring method. + +[![Screenshot](images/risk.scoring.create.png)](images/risk.scoring.create.png) + +#### Name and Formula + +- **Name** — label for the configuration, displayed in the list; +- **Formula** — the score calculation method. Four formulas are available: + +| Formula | Calculation | Recommended use | +|---|---|---| +| Probability × Impact | Score = P × I | Classic ISO 27005 / ISO 27001 | +| Likelihood × Impact | Score = (Exposure + Vulnerability) × I | BSI 200-3 / ISACA CSC-IT | +| Probability + Impact | Score = P + I | Quick triage | +| max(Probability, Impact) | Score = max(P, I) | Conservative approach | + +> Selecting the *Likelihood × Impact* formula displays the **Exposure** and **Vulnerability** sections in place of the **Probability** section. + +#### Levels + +Each assessment axis has a table of customisable levels. Each level is defined by: + +- **Value** — unique integer used in the score calculation; +- **Label** — short name displayed in the risk form; +- **Description** — explanatory text to guide users during assessment. + +The **Add level** button inserts an additional row. The delete button (trash icon) removes the row. A minimum of two levels is required per axis. + +The configurable axes are: + +- **Probability** — present for standard formulas; +- **Exposure** — present for the BSI formula (0 = off-network, 1 = internal network, 2 = Internet-facing); +- **Vulnerability** — present for the BSI formula (1 = none known, 2 = known but not exploitable, 3 = exploitable internally, 4 = remotely exploitable); +- **Impact** — present for all formulas. + +#### Classification Thresholds + +Thresholds define the mapping between a numerical score and a qualified risk level. Each threshold includes: + +- **Internal key** — technical identifier (e.g. `low`, `medium`, `high`, `critical`); +- **Label** — name displayed in badges and reports; +- **Max score** — upper bound of the threshold. The last threshold has no upper bound (catch-all); +- **Color** — MetroUI badge color: Green, Orange, Red, Dark Red, Blue, Grey. + +The **Preview** column displays a live badge reflecting the entered color and label. + +> Sort thresholds from the lowest to the highest score. The last threshold must always have the Max score field left empty. + +When you click: + +- **Save** — the configuration is saved (inactive by default on creation) and you return to the [configuration list](#scoring-list); +- **Cancel** — you return to the [configuration list](#scoring-list) without changes. + +--- + +## Integration with Other Modules + +The risk register integrates with Deming's existing modules: + +- **Controls** — a risk with *Mitigated* status can be linked to one or more security controls. The link is bidirectional: the risk detail page lists the associated controls and allows direct navigation to them. + +- **Action plans** — a risk with *Not accepted* status must be associated with one or more action plans. The risk detail page lists the associated action plans and allows direct navigation to them. + +- **Planning** — the next review date of each risk is managed independently of the control planning schedule, but can be manually aligned with your ISMS review cycles. + +- **Roles** — access restrictions follow the same model as the rest of the application: + +| Role | Access | +|---|---| +| Administrator | Read, create, edit, delete, scoring configuration | +| User | Read, create, edit | +| Auditor | Read-only (all risks) | +| Auditee | Read and edit risks they own only | \ No newline at end of file diff --git a/mkdocs.yaml b/mkdocs.yaml index 9534e215..67134b41 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -2,75 +2,83 @@ docs_dir: docs # Project information site_name: Deming -site_description: ISMS made easy ! +site_description: Information System made easy ! +site_url: https://dbarzin.githubs.io/deming/ # Repository repo_url: https://github.com/dbarzin/deming -edit_uri: edit/main/docs +edit_uri: edit/master/docs # Copyright copyright: Copyright GPL. theme: - name: readthedocs + name: material + language: en + features: + - navigation.sections + - navigation.expand + - navigation.top + - toc.integrate + - content.code.copy + - content.action.edit + - content.action.view + - palette.toggle + palette: + - scheme: default + primary: blue + accent: indigo + toggle: + icon: material/weather-night + name: Switch to dark mode + - scheme: slate + primary: blue + accent: indigo + toggle: + icon: material/weather-sunny + name: Switch to light mode extra_css: - - css/extra.css + - css/overrides.css + +extra: + generator: false plugins: - search - i18n: + docs_structure: suffix + fallback_to_default: true languages: - fr: "Français" - en: "English" - default_language: 'en' - no_translation: - fr: "Cette page n'a pas été traduite" - en: "This page isn't translated to English." - translate_nav: - fr: - section title: "Section" - subsection: "Sous-section" - page title: "Titre" - en: - section title: "Section" - subsection: "Subsection" - page title: "Page with title translated" + - locale: en + default: true + name: English + build: true + - locale: fr + name: Français + build: true + reconfigure_material: true -nav: -- Introduction: index.md -- Introduction: index.fr.md -- Homepage : home.md -- Page principale : home.fr.md -- Controls : measures.md -- Mesures : measures.fr.md -- Measures : controls.md -- Contrôles : controls.fr.md -- Delegation : delegation.md -- Délégation : delegation.fr.md -- Planning : plan.md -- Planning : plan.fr.md -- Dashboards : dashboards.md -- Tableaux de bords : dashboards.fr.md -- Action plans : actions.md -- Plans d'action : actions.fr.md -- Configuration : config.md -- Configuration : config.fr.md -- API: api.fr.md -- API: api.md -- Annexe: annexe.fr.md -- Annexe: annexe.md -- References: references.md -- Références: references.fr.md +markdown_extensions: + - attr_list + - admonition + - toc: + permalink: true + - pymdownx.highlight + - pymdownx.superfences -extra: - # Default mkdocs-material alternate links for untranslated pages - # https://squidfunk.github.io/mkdocs-material/setup/changing-the-language/#site-language-selector - alternate: - - name: English - link: "" - lang: en +nav: + - index.md + - home.md + - measures.md + - controls.md + - delegation.md + - plan.md + - dashboards.md + - actions.md + - risks.md + - config.md + - api.md + - annexe.md + - references.md - - name: Français - link: index.fr/ - lang: fr diff --git a/public/build/assets/app-SbWNGsLE.css b/public/build/assets/app-g_4aWm6R.css similarity index 99% rename from public/build/assets/app-SbWNGsLE.css rename to public/build/assets/app-g_4aWm6R.css index fb0ab71d..89c7b37d 100644 --- a/public/build/assets/app-SbWNGsLE.css +++ b/public/build/assets/app-g_4aWm6R.css @@ -3,4 +3,4 @@ * Copyright Jeroen Akkerman * @link https://github.com/ionaru/easy-markdown-editor * @license MIT - */.CodeMirror{font-family:monospace;height:300px;color:#000;direction:ltr}.CodeMirror-lines{padding:4px 0}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{padding:0 4px}.CodeMirror-gutter-filler,.CodeMirror-scrollbar-filler{background-color:#fff}.CodeMirror-gutters{border-right:1px solid #ddd;background-color:#f7f7f7;white-space:nowrap}.CodeMirror-linenumber{padding:0 3px 0 5px;min-width:20px;text-align:right;color:#999;white-space:nowrap}.CodeMirror-guttermarker{color:#000}.CodeMirror-guttermarker-subtle{color:#999}.CodeMirror-cursor{border-left:1px solid #000;border-right:none;width:0}.CodeMirror div.CodeMirror-secondarycursor{border-left:1px solid silver}.cm-fat-cursor .CodeMirror-cursor{width:auto;border:0!important;background:#7e7}.cm-fat-cursor div.CodeMirror-cursors{z-index:1}.cm-fat-cursor .CodeMirror-line::selection,.cm-fat-cursor .CodeMirror-line>span::selection,.cm-fat-cursor .CodeMirror-line>span>span::selection{background:0 0}.cm-fat-cursor .CodeMirror-line::-moz-selection,.cm-fat-cursor .CodeMirror-line>span::-moz-selection,.cm-fat-cursor .CodeMirror-line>span>span::-moz-selection{background:0 0}.cm-fat-cursor{caret-color:transparent}@-moz-keyframes blink{50%{background-color:transparent}}@-webkit-keyframes blink{50%{background-color:transparent}}@keyframes blink{50%{background-color:transparent}}.cm-tab{display:inline-block;text-decoration:inherit}.CodeMirror-rulers{position:absolute;left:0;right:0;top:-50px;bottom:0;overflow:hidden}.CodeMirror-ruler{border-left:1px solid #ccc;top:0;bottom:0;position:absolute}.cm-s-default .cm-header{color:#00f}.cm-s-default .cm-quote{color:#090}.cm-negative{color:#d44}.cm-positive{color:#292}.cm-header,.cm-strong{font-weight:700}.cm-em{font-style:italic}.cm-link{text-decoration:underline}.cm-strikethrough{text-decoration:line-through}.cm-s-default .cm-keyword{color:#708}.cm-s-default .cm-atom{color:#219}.cm-s-default .cm-number{color:#164}.cm-s-default .cm-def{color:#00f}.cm-s-default .cm-variable-2{color:#05a}.cm-s-default .cm-type,.cm-s-default .cm-variable-3{color:#085}.cm-s-default .cm-comment{color:#a50}.cm-s-default .cm-string{color:#a11}.cm-s-default .cm-string-2{color:#f50}.cm-s-default .cm-meta,.cm-s-default .cm-qualifier{color:#555}.cm-s-default .cm-builtin{color:#30a}.cm-s-default .cm-bracket{color:#997}.cm-s-default .cm-tag{color:#170}.cm-s-default .cm-attribute{color:#00c}.cm-s-default .cm-hr{color:#999}.cm-s-default .cm-link{color:#00c}.cm-s-default .cm-error,.cm-invalidchar{color:red}.CodeMirror-composing{border-bottom:2px solid}div.CodeMirror span.CodeMirror-matchingbracket{color:#0b0}div.CodeMirror span.CodeMirror-nonmatchingbracket{color:#a22}.CodeMirror-matchingtag{background:#ff96004d}.CodeMirror-activeline-background{background:#e8f2ff}.CodeMirror{position:relative;overflow:hidden;background:#fff}.CodeMirror-scroll{overflow:scroll!important;margin-bottom:-50px;margin-right:-50px;padding-bottom:50px;height:100%;outline:0;position:relative;z-index:0}.CodeMirror-sizer{position:relative;border-right:50px solid transparent}.CodeMirror-gutter-filler,.CodeMirror-hscrollbar,.CodeMirror-scrollbar-filler,.CodeMirror-vscrollbar{position:absolute;z-index:6;display:none;outline:0}.CodeMirror-vscrollbar{right:0;top:0;overflow-x:hidden;overflow-y:scroll}.CodeMirror-hscrollbar{bottom:0;left:0;overflow-y:hidden;overflow-x:scroll}.CodeMirror-scrollbar-filler{right:0;bottom:0}.CodeMirror-gutter-filler{left:0;bottom:0}.CodeMirror-gutters{position:absolute;left:0;top:0;min-height:100%;z-index:3}.CodeMirror-gutter{white-space:normal;height:100%;display:inline-block;vertical-align:top;margin-bottom:-50px}.CodeMirror-gutter-wrapper{position:absolute;z-index:4;background:0 0!important;border:none!important}.CodeMirror-gutter-background{position:absolute;top:0;bottom:0;z-index:4}.CodeMirror-gutter-elt{position:absolute;cursor:default;z-index:4}.CodeMirror-gutter-wrapper ::selection{background-color:transparent}.CodeMirror-gutter-wrapper ::-moz-selection{background-color:transparent}.CodeMirror-lines{cursor:text;min-height:1px}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{-moz-border-radius:0;-webkit-border-radius:0;border-radius:0;border-width:0;background:0 0;font-family:inherit;font-size:inherit;margin:0;white-space:pre;word-wrap:normal;line-height:inherit;color:inherit;z-index:2;position:relative;overflow:visible;-webkit-tap-highlight-color:transparent;-webkit-font-variant-ligatures:contextual;font-variant-ligatures:contextual}.CodeMirror-wrap pre.CodeMirror-line,.CodeMirror-wrap pre.CodeMirror-line-like{word-wrap:break-word;white-space:pre-wrap;word-break:normal}.CodeMirror-linebackground{position:absolute;left:0;right:0;top:0;bottom:0;z-index:0}.CodeMirror-linewidget{position:relative;z-index:2;padding:.1px}.CodeMirror-code{outline:0}.CodeMirror-gutter,.CodeMirror-gutters,.CodeMirror-linenumber,.CodeMirror-scroll,.CodeMirror-sizer{-moz-box-sizing:content-box;box-sizing:content-box}.CodeMirror-measure{position:absolute;width:100%;height:0;overflow:hidden;visibility:hidden}.CodeMirror-cursor{position:absolute;pointer-events:none}.CodeMirror-measure pre{position:static}div.CodeMirror-cursors{visibility:hidden;position:relative;z-index:3}div.CodeMirror-dragcursors,.CodeMirror-focused div.CodeMirror-cursors{visibility:visible}.CodeMirror-selected{background:#d9d9d9}.CodeMirror-focused .CodeMirror-selected{background:#d7d4f0}.CodeMirror-crosshair{cursor:crosshair}.CodeMirror-line::selection,.CodeMirror-line>span::selection,.CodeMirror-line>span>span::selection{background:#d7d4f0}.CodeMirror-line::-moz-selection,.CodeMirror-line>span::-moz-selection,.CodeMirror-line>span>span::-moz-selection{background:#d7d4f0}.cm-searching{background-color:#ffa;background-color:#ff06}.cm-force-border{padding-right:.1px}@media print{.CodeMirror div.CodeMirror-cursors{visibility:hidden}}.cm-tab-wrap-hack:after{content:""}span.CodeMirror-selectedtext{background:0 0}.EasyMDEContainer{display:block}.CodeMirror-rtl pre{direction:rtl}.EasyMDEContainer.sided--no-fullscreen{display:flex;flex-direction:row;flex-wrap:wrap}.EasyMDEContainer .CodeMirror{box-sizing:border-box;height:auto;border:1px solid #ced4da;border-bottom-left-radius:4px;border-bottom-right-radius:4px;padding:10px;font:inherit;z-index:0;word-wrap:break-word}.EasyMDEContainer .CodeMirror-scroll{cursor:text}.EasyMDEContainer .CodeMirror-fullscreen{background:#fff;position:fixed!important;top:50px;left:0;right:0;bottom:0;height:auto;z-index:8;border-right:none!important;border-bottom-right-radius:0!important}.EasyMDEContainer .CodeMirror-sided{width:50%!important}.EasyMDEContainer.sided--no-fullscreen .CodeMirror-sided{border-right:none!important;border-bottom-right-radius:0;position:relative;flex:1 1 auto}.EasyMDEContainer .CodeMirror-placeholder{opacity:.5}.EasyMDEContainer .CodeMirror-focused .CodeMirror-selected{background:#d9d9d9}.editor-toolbar{position:relative;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none;user-select:none;padding:9px 10px;border-top:1px solid #ced4da;border-left:1px solid #ced4da;border-right:1px solid #ced4da;border-top-left-radius:4px;border-top-right-radius:4px}.editor-toolbar.fullscreen{width:100%;height:50px;padding-top:10px;padding-bottom:10px;box-sizing:border-box;background:#fff;border:0;position:fixed;top:0;left:0;opacity:1;z-index:9}.editor-toolbar.fullscreen:before{width:20px;height:50px;background:-moz-linear-gradient(left,#fff 0,rgba(255,255,255,0) 100%);background:-webkit-gradient(linear,left top,right top,color-stop(0,#fff),color-stop(100%,rgba(255,255,255,0)));background:-webkit-linear-gradient(left,#fff 0,rgba(255,255,255,0) 100%);background:-o-linear-gradient(left,#fff 0,rgba(255,255,255,0) 100%);background:-ms-linear-gradient(left,#fff 0,rgba(255,255,255,0) 100%);background:linear-gradient(to right,#fff 0,#fff0);position:fixed;top:0;left:0;margin:0;padding:0}.editor-toolbar.fullscreen:after{width:20px;height:50px;background:-moz-linear-gradient(left,rgba(255,255,255,0) 0,#fff 100%);background:-webkit-gradient(linear,left top,right top,color-stop(0,rgba(255,255,255,0)),color-stop(100%,#fff));background:-webkit-linear-gradient(left,rgba(255,255,255,0) 0,#fff 100%);background:-o-linear-gradient(left,rgba(255,255,255,0) 0,#fff 100%);background:-ms-linear-gradient(left,rgba(255,255,255,0) 0,#fff 100%);background:linear-gradient(to right,#fff0 0,#fff);position:fixed;top:0;right:0;margin:0;padding:0}.EasyMDEContainer.sided--no-fullscreen .editor-toolbar{width:100%}.editor-toolbar .easymde-dropdown,.editor-toolbar button{background:0 0;display:inline-block;text-align:center;text-decoration:none!important;height:30px;margin:0;padding:0;border:1px solid transparent;border-radius:3px;cursor:pointer}.editor-toolbar button{font-weight:700;min-width:30px;padding:0 6px;white-space:nowrap}.editor-toolbar button.active,.editor-toolbar button:hover{background:#fcfcfc;border-color:#95a5a6}.editor-toolbar i.separator{display:inline-block;width:0;border-left:1px solid #d9d9d9;border-right:1px solid #fff;color:transparent;text-indent:-10px;margin:0 6px}.editor-toolbar button:after{font-family:Arial,Helvetica Neue,Helvetica,sans-serif;font-size:65%;vertical-align:text-bottom;position:relative;top:2px}.editor-toolbar button.heading-1:after{content:"1"}.editor-toolbar button.heading-2:after{content:"2"}.editor-toolbar button.heading-3:after{content:"3"}.editor-toolbar button.heading-bigger:after{content:"▲"}.editor-toolbar button.heading-smaller:after{content:"▼"}.editor-toolbar.disabled-for-preview button:not(.no-disable){opacity:.6;pointer-events:none}@media only screen and (max-width:700px){.editor-toolbar i.no-mobile{display:none}}.editor-statusbar{padding:8px 10px;font-size:12px;color:#959694;text-align:right}.EasyMDEContainer.sided--no-fullscreen .editor-statusbar{width:100%}.editor-statusbar span{display:inline-block;min-width:4em;margin-left:1em}.editor-statusbar .lines:before{content:"lines: "}.editor-statusbar .words:before{content:"words: "}.editor-statusbar .characters:before{content:"characters: "}.editor-preview-full{position:absolute;width:100%;height:100%;top:0;left:0;z-index:7;overflow:auto;display:none;box-sizing:border-box}.editor-preview-side{position:fixed;bottom:0;width:50%;top:50px;right:0;z-index:9;overflow:auto;display:none;box-sizing:border-box;border:1px solid #ddd;word-wrap:break-word}.editor-preview-active-side{display:block}.EasyMDEContainer.sided--no-fullscreen .editor-preview-active-side{flex:1 1 auto;height:auto;position:static}.editor-preview-active{display:block}.editor-preview{padding:10px;background:#fafafa}.editor-preview>p{margin-top:0}.editor-preview pre{background:#eee;margin-bottom:10px}.editor-preview table td,.editor-preview table th{border:1px solid #ddd;padding:5px}.cm-s-easymde .cm-tag{color:#63a35c}.cm-s-easymde .cm-attribute{color:#795da3}.cm-s-easymde .cm-string{color:#183691}.cm-s-easymde .cm-header-1{font-size:calc(1.375rem + 1.5vw)}.cm-s-easymde .cm-header-2{font-size:calc(1.325rem + .9vw)}.cm-s-easymde .cm-header-3{font-size:calc(1.3rem + .6vw)}.cm-s-easymde .cm-header-4{font-size:calc(1.275rem + .3vw)}.cm-s-easymde .cm-header-5{font-size:1.25rem}.cm-s-easymde .cm-header-6{font-size:1rem}.cm-s-easymde .cm-header-1,.cm-s-easymde .cm-header-2,.cm-s-easymde .cm-header-3,.cm-s-easymde .cm-header-4,.cm-s-easymde .cm-header-5,.cm-s-easymde .cm-header-6{margin-bottom:.5rem;line-height:1.2}.cm-s-easymde .cm-comment{background:#0000000d;border-radius:2px}.cm-s-easymde .cm-link{color:#7f8c8d}.cm-s-easymde .cm-url{color:#aab2b3}.cm-s-easymde .cm-quote{color:#7f8c8d;font-style:italic}.editor-toolbar .easymde-dropdown{position:relative;background:linear-gradient(to bottom right,#fff 0,#fff 84%,#333 50%,#333);border-radius:0;border:1px solid #fff}.editor-toolbar .easymde-dropdown:hover{background:linear-gradient(to bottom right,#fff 0,#fff 84%,#333 50%,#333)}.easymde-dropdown-content{display:block;visibility:hidden;position:absolute;background-color:#f9f9f9;box-shadow:0 8px 16px #0003;padding:8px;z-index:2;top:30px}.easymde-dropdown:active .easymde-dropdown-content,.easymde-dropdown:focus .easymde-dropdown-content,.easymde-dropdown:focus-within .easymde-dropdown-content{visibility:visible}.easymde-dropdown-content button{display:block}span[data-img-src]:after{content:"";background-image:var(--bg-image);display:block;max-height:100%;max-width:100%;background-size:contain;height:0;padding-top:var(--height);width:var(--width);background-repeat:no-repeat}.CodeMirror .cm-spell-error:not(.cm-url):not(.cm-comment):not(.cm-tag):not(.cm-word){background:#ff000026}.button.primary{background-color:#0366d6!important;border-color:#0366d6!important;color:#fff!important;outline-color:#75b5fd!important}.button.primary:hover{background-color:#024ea4!important;border-color:#023671!important}input[type=file]{display:block;font-size:16px;padding:3px;width:100%;border-radius:5px;border:1px solid #C0C0C0}a.no-underline:hover{text-decoration:none} + */.CodeMirror{font-family:monospace;height:300px;color:#000;direction:ltr}.CodeMirror-lines{padding:4px 0}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{padding:0 4px}.CodeMirror-gutter-filler,.CodeMirror-scrollbar-filler{background-color:#fff}.CodeMirror-gutters{border-right:1px solid #ddd;background-color:#f7f7f7;white-space:nowrap}.CodeMirror-linenumber{padding:0 3px 0 5px;min-width:20px;text-align:right;color:#999;white-space:nowrap}.CodeMirror-guttermarker{color:#000}.CodeMirror-guttermarker-subtle{color:#999}.CodeMirror-cursor{border-left:1px solid #000;border-right:none;width:0}.CodeMirror div.CodeMirror-secondarycursor{border-left:1px solid silver}.cm-fat-cursor .CodeMirror-cursor{width:auto;border:0!important;background:#7e7}.cm-fat-cursor div.CodeMirror-cursors{z-index:1}.cm-fat-cursor .CodeMirror-line::selection,.cm-fat-cursor .CodeMirror-line>span::selection,.cm-fat-cursor .CodeMirror-line>span>span::selection{background:0 0}.cm-fat-cursor .CodeMirror-line::-moz-selection,.cm-fat-cursor .CodeMirror-line>span::-moz-selection,.cm-fat-cursor .CodeMirror-line>span>span::-moz-selection{background:0 0}.cm-fat-cursor{caret-color:transparent}@-moz-keyframes blink{50%{background-color:transparent}}@-webkit-keyframes blink{50%{background-color:transparent}}@keyframes blink{50%{background-color:transparent}}.cm-tab{display:inline-block;text-decoration:inherit}.CodeMirror-rulers{position:absolute;left:0;right:0;top:-50px;bottom:0;overflow:hidden}.CodeMirror-ruler{border-left:1px solid #ccc;top:0;bottom:0;position:absolute}.cm-s-default .cm-header{color:#00f}.cm-s-default .cm-quote{color:#090}.cm-negative{color:#d44}.cm-positive{color:#292}.cm-header,.cm-strong{font-weight:700}.cm-em{font-style:italic}.cm-link{text-decoration:underline}.cm-strikethrough{text-decoration:line-through}.cm-s-default .cm-keyword{color:#708}.cm-s-default .cm-atom{color:#219}.cm-s-default .cm-number{color:#164}.cm-s-default .cm-def{color:#00f}.cm-s-default .cm-variable-2{color:#05a}.cm-s-default .cm-type,.cm-s-default .cm-variable-3{color:#085}.cm-s-default .cm-comment{color:#a50}.cm-s-default .cm-string{color:#a11}.cm-s-default .cm-string-2{color:#f50}.cm-s-default .cm-meta,.cm-s-default .cm-qualifier{color:#555}.cm-s-default .cm-builtin{color:#30a}.cm-s-default .cm-bracket{color:#997}.cm-s-default .cm-tag{color:#170}.cm-s-default .cm-attribute{color:#00c}.cm-s-default .cm-hr{color:#999}.cm-s-default .cm-link{color:#00c}.cm-s-default .cm-error,.cm-invalidchar{color:red}.CodeMirror-composing{border-bottom:2px solid}div.CodeMirror span.CodeMirror-matchingbracket{color:#0b0}div.CodeMirror span.CodeMirror-nonmatchingbracket{color:#a22}.CodeMirror-matchingtag{background:#ff96004d}.CodeMirror-activeline-background{background:#e8f2ff}.CodeMirror{position:relative;overflow:hidden;background:#fff}.CodeMirror-scroll{overflow:scroll!important;margin-bottom:-50px;margin-right:-50px;padding-bottom:50px;height:100%;outline:0;position:relative;z-index:0}.CodeMirror-sizer{position:relative;border-right:50px solid transparent}.CodeMirror-gutter-filler,.CodeMirror-hscrollbar,.CodeMirror-scrollbar-filler,.CodeMirror-vscrollbar{position:absolute;z-index:6;display:none;outline:0}.CodeMirror-vscrollbar{right:0;top:0;overflow-x:hidden;overflow-y:scroll}.CodeMirror-hscrollbar{bottom:0;left:0;overflow-y:hidden;overflow-x:scroll}.CodeMirror-scrollbar-filler{right:0;bottom:0}.CodeMirror-gutter-filler{left:0;bottom:0}.CodeMirror-gutters{position:absolute;left:0;top:0;min-height:100%;z-index:3}.CodeMirror-gutter{white-space:normal;height:100%;display:inline-block;vertical-align:top;margin-bottom:-50px}.CodeMirror-gutter-wrapper{position:absolute;z-index:4;background:0 0!important;border:none!important}.CodeMirror-gutter-background{position:absolute;top:0;bottom:0;z-index:4}.CodeMirror-gutter-elt{position:absolute;cursor:default;z-index:4}.CodeMirror-gutter-wrapper ::selection{background-color:transparent}.CodeMirror-gutter-wrapper ::-moz-selection{background-color:transparent}.CodeMirror-lines{cursor:text;min-height:1px}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{-moz-border-radius:0;-webkit-border-radius:0;border-radius:0;border-width:0;background:0 0;font-family:inherit;font-size:inherit;margin:0;white-space:pre;word-wrap:normal;line-height:inherit;color:inherit;z-index:2;position:relative;overflow:visible;-webkit-tap-highlight-color:transparent;-webkit-font-variant-ligatures:contextual;font-variant-ligatures:contextual}.CodeMirror-wrap pre.CodeMirror-line,.CodeMirror-wrap pre.CodeMirror-line-like{word-wrap:break-word;white-space:pre-wrap;word-break:normal}.CodeMirror-linebackground{position:absolute;left:0;right:0;top:0;bottom:0;z-index:0}.CodeMirror-linewidget{position:relative;z-index:2;padding:.1px}.CodeMirror-code{outline:0}.CodeMirror-gutter,.CodeMirror-gutters,.CodeMirror-linenumber,.CodeMirror-scroll,.CodeMirror-sizer{-moz-box-sizing:content-box;box-sizing:content-box}.CodeMirror-measure{position:absolute;width:100%;height:0;overflow:hidden;visibility:hidden}.CodeMirror-cursor{position:absolute;pointer-events:none}.CodeMirror-measure pre{position:static}div.CodeMirror-cursors{visibility:hidden;position:relative;z-index:3}div.CodeMirror-dragcursors,.CodeMirror-focused div.CodeMirror-cursors{visibility:visible}.CodeMirror-selected{background:#d9d9d9}.CodeMirror-focused .CodeMirror-selected{background:#d7d4f0}.CodeMirror-crosshair{cursor:crosshair}.CodeMirror-line::selection,.CodeMirror-line>span::selection,.CodeMirror-line>span>span::selection{background:#d7d4f0}.CodeMirror-line::-moz-selection,.CodeMirror-line>span::-moz-selection,.CodeMirror-line>span>span::-moz-selection{background:#d7d4f0}.cm-searching{background-color:#ffa;background-color:#ff06}.cm-force-border{padding-right:.1px}@media print{.CodeMirror div.CodeMirror-cursors{visibility:hidden}}.cm-tab-wrap-hack:after{content:""}span.CodeMirror-selectedtext{background:0 0}.EasyMDEContainer{display:block}.CodeMirror-rtl pre{direction:rtl}.EasyMDEContainer.sided--no-fullscreen{display:flex;flex-direction:row;flex-wrap:wrap}.EasyMDEContainer .CodeMirror{box-sizing:border-box;height:auto;border:1px solid #ced4da;border-bottom-left-radius:4px;border-bottom-right-radius:4px;padding:10px;font:inherit;z-index:0;word-wrap:break-word}.EasyMDEContainer .CodeMirror-scroll{cursor:text}.EasyMDEContainer .CodeMirror-fullscreen{background:#fff;position:fixed!important;top:50px;left:0;right:0;bottom:0;height:auto;z-index:8;border-right:none!important;border-bottom-right-radius:0!important}.EasyMDEContainer .CodeMirror-sided{width:50%!important}.EasyMDEContainer.sided--no-fullscreen .CodeMirror-sided{border-right:none!important;border-bottom-right-radius:0;position:relative;flex:1 1 auto}.EasyMDEContainer .CodeMirror-placeholder{opacity:.5}.EasyMDEContainer .CodeMirror-focused .CodeMirror-selected{background:#d9d9d9}.editor-toolbar{position:relative;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none;user-select:none;padding:9px 10px;border-top:1px solid #ced4da;border-left:1px solid #ced4da;border-right:1px solid #ced4da;border-top-left-radius:4px;border-top-right-radius:4px}.editor-toolbar.fullscreen{width:100%;height:50px;padding-top:10px;padding-bottom:10px;box-sizing:border-box;background:#fff;border:0;position:fixed;top:0;left:0;opacity:1;z-index:9}.editor-toolbar.fullscreen:before{width:20px;height:50px;background:-moz-linear-gradient(left,#fff 0,rgba(255,255,255,0) 100%);background:-webkit-gradient(linear,left top,right top,color-stop(0,#fff),color-stop(100%,rgba(255,255,255,0)));background:-webkit-linear-gradient(left,#fff 0,rgba(255,255,255,0) 100%);background:-o-linear-gradient(left,#fff 0,rgba(255,255,255,0) 100%);background:-ms-linear-gradient(left,#fff 0,rgba(255,255,255,0) 100%);background:linear-gradient(to right,#fff 0,#fff0);position:fixed;top:0;left:0;margin:0;padding:0}.editor-toolbar.fullscreen:after{width:20px;height:50px;background:-moz-linear-gradient(left,rgba(255,255,255,0) 0,#fff 100%);background:-webkit-gradient(linear,left top,right top,color-stop(0,rgba(255,255,255,0)),color-stop(100%,#fff));background:-webkit-linear-gradient(left,rgba(255,255,255,0) 0,#fff 100%);background:-o-linear-gradient(left,rgba(255,255,255,0) 0,#fff 100%);background:-ms-linear-gradient(left,rgba(255,255,255,0) 0,#fff 100%);background:linear-gradient(to right,#fff0 0,#fff);position:fixed;top:0;right:0;margin:0;padding:0}.EasyMDEContainer.sided--no-fullscreen .editor-toolbar{width:100%}.editor-toolbar .easymde-dropdown,.editor-toolbar button{background:0 0;display:inline-block;text-align:center;text-decoration:none!important;height:30px;margin:0;padding:0;border:1px solid transparent;border-radius:3px;cursor:pointer}.editor-toolbar button{font-weight:700;min-width:30px;padding:0 6px;white-space:nowrap}.editor-toolbar button.active,.editor-toolbar button:hover{background:#fcfcfc;border-color:#95a5a6}.editor-toolbar i.separator{display:inline-block;width:0;border-left:1px solid #d9d9d9;border-right:1px solid #fff;color:transparent;text-indent:-10px;margin:0 6px}.editor-toolbar button:after{font-family:Arial,Helvetica Neue,Helvetica,sans-serif;font-size:65%;vertical-align:text-bottom;position:relative;top:2px}.editor-toolbar button.heading-1:after{content:"1"}.editor-toolbar button.heading-2:after{content:"2"}.editor-toolbar button.heading-3:after{content:"3"}.editor-toolbar button.heading-bigger:after{content:"▲"}.editor-toolbar button.heading-smaller:after{content:"▼"}.editor-toolbar.disabled-for-preview button:not(.no-disable){opacity:.6;pointer-events:none}@media only screen and (max-width:700px){.editor-toolbar i.no-mobile{display:none}}.editor-statusbar{padding:8px 10px;font-size:12px;color:#959694;text-align:right}.EasyMDEContainer.sided--no-fullscreen .editor-statusbar{width:100%}.editor-statusbar span{display:inline-block;min-width:4em;margin-left:1em}.editor-statusbar .lines:before{content:"lines: "}.editor-statusbar .words:before{content:"words: "}.editor-statusbar .characters:before{content:"characters: "}.editor-preview-full{position:absolute;width:100%;height:100%;top:0;left:0;z-index:7;overflow:auto;display:none;box-sizing:border-box}.editor-preview-side{position:fixed;bottom:0;width:50%;top:50px;right:0;z-index:9;overflow:auto;display:none;box-sizing:border-box;border:1px solid #ddd;word-wrap:break-word}.editor-preview-active-side{display:block}.EasyMDEContainer.sided--no-fullscreen .editor-preview-active-side{flex:1 1 auto;height:auto;position:static}.editor-preview-active{display:block}.editor-preview{padding:10px;background:#fafafa}.editor-preview>p{margin-top:0}.editor-preview pre{background:#eee;margin-bottom:10px}.editor-preview table td,.editor-preview table th{border:1px solid #ddd;padding:5px}.cm-s-easymde .cm-tag{color:#63a35c}.cm-s-easymde .cm-attribute{color:#795da3}.cm-s-easymde .cm-string{color:#183691}.cm-s-easymde .cm-header-1{font-size:calc(1.375rem + 1.5vw)}.cm-s-easymde .cm-header-2{font-size:calc(1.325rem + .9vw)}.cm-s-easymde .cm-header-3{font-size:calc(1.3rem + .6vw)}.cm-s-easymde .cm-header-4{font-size:calc(1.275rem + .3vw)}.cm-s-easymde .cm-header-5{font-size:1.25rem}.cm-s-easymde .cm-header-6{font-size:1rem}.cm-s-easymde .cm-header-1,.cm-s-easymde .cm-header-2,.cm-s-easymde .cm-header-3,.cm-s-easymde .cm-header-4,.cm-s-easymde .cm-header-5,.cm-s-easymde .cm-header-6{margin-bottom:.5rem;line-height:1.2}.cm-s-easymde .cm-comment{background:#0000000d;border-radius:2px}.cm-s-easymde .cm-link{color:#7f8c8d}.cm-s-easymde .cm-url{color:#aab2b3}.cm-s-easymde .cm-quote{color:#7f8c8d;font-style:italic}.editor-toolbar .easymde-dropdown{position:relative;background:linear-gradient(to bottom right,#fff 0,#fff 84%,#333 50%,#333);border-radius:0;border:1px solid #fff}.editor-toolbar .easymde-dropdown:hover{background:linear-gradient(to bottom right,#fff 0,#fff 84%,#333 50%,#333)}.easymde-dropdown-content{display:block;visibility:hidden;position:absolute;background-color:#f9f9f9;box-shadow:0 8px 16px #0003;padding:8px;z-index:2;top:30px}.easymde-dropdown:active .easymde-dropdown-content,.easymde-dropdown:focus .easymde-dropdown-content,.easymde-dropdown:focus-within .easymde-dropdown-content{visibility:visible}.easymde-dropdown-content button{display:block}span[data-img-src]:after{content:"";background-image:var(--bg-image);display:block;max-height:100%;max-width:100%;background-size:contain;height:0;padding-top:var(--height);width:var(--width);background-repeat:no-repeat}.CodeMirror .cm-spell-error:not(.cm-url):not(.cm-comment):not(.cm-tag):not(.cm-word){background:#ff000026}.button.primary{background-color:#0366d6!important;border-color:#0366d6!important;color:#fff!important;outline-color:#75b5fd!important}.button.primary:hover{background-color:#024ea4!important;border-color:#023671!important}input[type=file]{display:block;font-size:16px;padding:3px;width:100%;border-radius:5px;border:1px solid #C0C0C0}a.no-underline:hover{text-decoration:none}label.input-normal.select.multiple div.select-input{height:auto!important;min-height:30px!important;overflow:visible!important;flex-wrap:wrap!important}label.input-normal.select.multiple div.tag.short-tag{width:auto!important;max-width:none!important;height:auto!important}label.input-normal.select.multiple div.tag.short-tag span.title{white-space:normal!important;overflow:visible!important;text-overflow:unset!important;max-width:none!important;width:auto!important} diff --git a/public/build/manifest.json b/public/build/manifest.json index 64956d95..7f5cfcc3 100644 --- a/public/build/manifest.json +++ b/public/build/manifest.json @@ -1,6 +1,6 @@ { "resources/css/app.css": { - "file": "assets/app-SbWNGsLE.css", + "file": "assets/app-g_4aWm6R.css", "src": "resources/css/app.css", "isEntry": true }, diff --git a/resources/css/app.css b/resources/css/app.css index 3f1848e6..0ff68e36 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -1,10 +1,5 @@ /* Metro NPM */ @import "@olton/metroui/lib/metro.all.css"; -/* Metro Dev */ -/* -@import '../metro5.1.3/metro.css'; -@import '../metro5.1.3/icons.css'; -*/ @import 'calendar.css'; @import 'custom.css'; @@ -31,10 +26,33 @@ input[type="file"] { width: 100%; border-radius: 5px; border: 1px solid #C0C0C0; - } a.no-underline:hover { text-decoration: none; } +/*******************************************************/ +/* fixe la largeur des labels dans les select multiple */ + +label.input-normal.select.multiple div.select-input { + height: auto !important; + min-height: 30px !important; + overflow: visible !important; + flex-wrap: wrap !important; +} + +label.input-normal.select.multiple div.tag.short-tag { + width: auto !important; + max-width: none !important; + height: auto !important; +} + +label.input-normal.select.multiple div.tag.short-tag span.title { + white-space: normal !important; + overflow: visible !important; + text-overflow: unset !important; + max-width: none !important; + width: auto !important; + +}/*******************************************************/ diff --git a/resources/lang/de/menu.php b/resources/lang/de/menu.php index fa3d8b3c..c57ff28d 100644 --- a/resources/lang/de/menu.php +++ b/resources/lang/de/menu.php @@ -23,5 +23,7 @@ 'notifications' => 'Benachrichtigungen' ], 'test' => 'Sie befinden sich in einer Test-Umgebung - Die Daten sind fiktiv', - 'logout' => 'Logout' -]; + 'logout' => 'Logout', + 'risks' => 'Risiken', + 'exceptions' => 'Ausnahmen', + ]; diff --git a/resources/lang/en/common.php b/resources/lang/en/common.php index f9e62649..349fd7ff 100644 --- a/resources/lang/en/common.php +++ b/resources/lang/en/common.php @@ -3,6 +3,7 @@ 'confirm' => 'Confirm deletion', 'accept' => 'Accept', + 'active' => 'Active', 'cancel' => 'Cancel', 'check' => 'Check', 'create' => 'Create', @@ -11,6 +12,7 @@ 'delete' => 'Delete', 'download' => 'Download', 'edit' => 'Edit', + 'export' => 'Export', 'history' => 'History', 'import' => 'Import', 'make' => 'Make', diff --git a/resources/lang/en/cruds.php b/resources/lang/en/cruds.php index 24b690eb..08c36f86 100644 --- a/resources/lang/en/cruds.php +++ b/resources/lang/en/cruds.php @@ -338,4 +338,130 @@ ], ], ], - ]; + + // ------------------------------------------------------------------------- + // Risk register + // ------------------------------------------------------------------------- + 'risk' => [ + + // Page titles + 'list' => 'Risk Register', + 'title_singular' => 'Risk', + 'create' => 'New Risk', + 'edit' => 'Edit Risk', + 'matrix' => 'Risk Matrix', + 'singular' => 'risk', + 'plural' => 'risks', + + // Risk levels (displayed in badges and counters) + 'levels' => [ + 'low' => 'Low', + 'medium' => 'Medium', + 'high' => 'High', + 'critical' => 'Critical', + ], + + // Form fields and list columns + 'fields' => [ + 'name' => 'Name', + 'description' => 'Description', + 'owner' => 'Owner', + 'no_owner' => 'Unassigned', + 'choose_owner' => 'Select an owner', + 'choose_status' => 'Select a status', + + // Assessment + 'probability' => 'Probability', + 'probability_comment' => 'Probability comment', + 'impact' => 'Impact', + 'impact_comment' => 'Impact comment', + 'exposure' => 'Exposure', + 'vulnerability' => 'Vulnerability', + 'likelihood' => 'Likelihood', + 'score' => 'Score', + + // Treatment + 'status' => 'Treatment status', + 'status_comment' => 'Status comment', + 'measures' => 'Linked controls', + 'measures_hint' => 'Required when status = Mitigated', + 'action_plan' => 'Linked action plans', + 'actions_hint' => 'Required when status = Not accepted', + + // Planning + 'review_frequency' => 'Review frequency', + 'next_review' => 'Next review', + 'overdue' => 'Overdue review', + 'overdue_all' => 'All', + 'overdue_only' => 'Overdue', + + // Dashboard / matrix + 'total' => 'Total', + 'by_status' => 'Distribution by status', + ], + + // Treatment statuses + 'status' => [ + 'not_evaluated' => 'Not evaluated', + 'not_accepted' => 'Not accepted', + 'temporarily_accepted' => 'Temporarily accepted', + 'accepted' => 'Accepted', + 'mitigated' => 'Mitigated', + 'transferred' => 'Transferred', + 'avoided' => 'Avoided', + ], + ], + + // ------------------------------------------------------------------------- + // Scoring engine configuration + // ------------------------------------------------------------------------- + 'risk_scoring' => [ + + // Page titles + 'list' => 'Risk Scoring Methods', + 'create' => 'New Scoring Configuration', + 'edit' => 'Edit Scoring Configuration', + 'activate' => 'Activate this configuration', + + // Level / threshold actions + 'add_level' => 'Add level', + 'add_threshold' => 'Add threshold', + + // Contextual hints + 'levels_hint' => 'Minimum 2 levels. Value must be a unique integer.', + 'thresholds_hint' => 'The last threshold has no upper bound (catch-all). Sort from lowest to highest score.', + + // Form fields + 'fields' => [ + 'name' => 'Configuration name', + 'formula' => 'Calculation formula', + 'levels' => 'Levels', + 'thresholds' => 'Classification thresholds', + 'value' => 'Value', + 'label' => 'Label', + 'description' => 'Description', + 'level_key' => 'Internal key', + 'score_max' => 'Max score (∞ = last)', + 'color' => 'Badge color', + ], + + // Available badge colors + 'colors' => [ + 'success' => 'Green', + 'warning' => 'Orange', + 'danger' => 'Red', + 'alert' => 'Dark red', + 'info' => 'Blue', + 'secondary' => 'Grey', + ], + + // Available formulas (labels) + 'formulas' => [ + 'probability_x_impact' => 'Probability × Impact', + 'likelihood_x_impact' => 'Likelihood × Impact (BSI 200-3)', + 'additive' => 'Probability + Impact', + 'max_pi' => 'max(Probability, Impact)', + ], + ], + +]; diff --git a/resources/lang/en/menu.php b/resources/lang/en/menu.php index a650b4ac..dd6e5a29 100644 --- a/resources/lang/en/menu.php +++ b/resources/lang/en/menu.php @@ -17,6 +17,7 @@ 'title' => 'Configuration', 'users' => 'Users', 'groups' => 'Groups', + 'scoring' => 'Scoring', 'reports' => 'Reports', 'import' => "Import", 'documents' => 'Documents', @@ -24,5 +25,7 @@ 'notifications' => 'Notifications' ], 'test' => 'You are in a test environment - The data is fictitious', - 'logout' => 'Logout' + 'logout' => 'Logout', + 'risks' => 'Risk matrix', + 'exceptions' => 'Exceptions', ]; diff --git a/resources/lang/fr/common.php b/resources/lang/fr/common.php index ea482362..be043962 100644 --- a/resources/lang/fr/common.php +++ b/resources/lang/fr/common.php @@ -3,6 +3,7 @@ 'confirm' => 'Confirmer la suppression ?', 'accept' => 'Accepter', + 'active' => 'Actif', 'cancel' => 'Annuler', 'check' => 'Vérifier', 'clone' => 'Copier', @@ -11,6 +12,7 @@ 'delete' => 'Supprimer', 'download' => 'Télécharger', 'edit' => 'Modifier', + 'export' => 'Exporter', 'history' => 'Historique', 'import' => 'Importer', 'make' => 'Faire', @@ -48,5 +50,7 @@ 'never' => 'Jamais', 'day' => 'Quotidiennement', 'week' => 'Hebdomadairement', - 'month' => 'Mensuellement' + 'month' => 'Mensuellement', + + 'months' => 'Mois', ]; diff --git a/resources/lang/fr/cruds.php b/resources/lang/fr/cruds.php index d4868580..f84e9d0d 100644 --- a/resources/lang/fr/cruds.php +++ b/resources/lang/fr/cruds.php @@ -203,6 +203,7 @@ 'measures_export' => 'Exportation des contrôles', 'controls_export' => 'Exportation des mesures de sécurité', 'actions_export' => 'Exporter les plans d\'action', + 'risks_export' => 'Exporter le registre des risques', ], 'group' => [ 'index' => 'Liste des groupes', @@ -335,5 +336,131 @@ 'months' => 'mois', ], ] - ] + ], + + // ------------------------------------------------------------------------- + // Registre des risques + // ------------------------------------------------------------------------- + 'risk' => [ + + // Titres de page + 'list' => 'Liste des risques', + 'create' => 'Nouveau risque', + 'edit' => 'Modifier le risque', + 'matrix' => 'Matrice des risques', + 'singular' => 'Risque', + 'plural' => 'Risques', + 'export' => 'Risques', + + // Niveaux de risque (affichés dans les badges et compteurs) + 'levels' => [ + 'low' => 'Faible', + 'medium' => 'Moyen', + 'high' => 'Élevé', + 'critical' => 'Critique', + ], + + // Champs du formulaire et de la liste + 'fields' => [ + 'name' => 'Nom', + 'description' => 'Description', + 'owner' => 'Propriétaire', + 'no_owner' => 'Non assigné', + 'choose_owner' => 'Choisir un propriétaire', + 'choose_status' => 'Choisir un statut', + + // Évaluation + 'probability' => 'Probabilité', + 'probability_comment' => 'Commentaire probabilité', + 'impact' => 'Impact', + 'impact_comment' => 'Commentaire impact', + 'exposure' => 'Exposition', + 'vulnerability' => 'Vulnérabilité', + 'likelihood' => 'Vraisemblance', + 'score' => 'Score', + + // Traitement + 'status' => 'Statut de traitement', + 'status_comment' => 'Commentaire statut', + 'measures' => 'Contrôles liés', + 'measures_hint' => 'Requis si statut = Mitigé', + 'action_plan' => 'Plans d\'action liés', + 'actions_hint' => 'Requis si statut = Non accepté', + + // Planification + 'review_frequency' => 'Fréquence de revue', + 'next_review' => 'Prochaine revue', + 'overdue' => 'En retard de revue', + 'overdue_all' => 'Tous', + 'overdue_only' => 'En retard', + + // Dashboard / matrice + 'total' => 'Total', + 'by_status' => 'Répartition par statut', + 'by_risks' => 'Répartition par risques', + ], + + // Statuts de traitement + 'status' => [ + 'not_evaluated' => 'Non évalué', + 'not_accepted' => 'Non accepté', + 'temporarily_accepted' => 'Accepté temporairement', + 'accepted' => 'Accepté', + 'mitigated' => 'Mitigé', + 'transferred' => 'Transféré', + 'avoided' => 'Évité', + ], + ], + + // ------------------------------------------------------------------------- + // Configuration du moteur de scoring + // ------------------------------------------------------------------------- + 'risk_scoring' => [ + + // Titres de page + 'list' => 'Méthodes de classification des risques', + 'create' => 'Nouvelle classification', + 'edit' => 'Modifier la classification', + 'activate' => 'Activer cette configuration', + + // Actions sur les niveaux / seuils + 'add_level' => 'Ajouter un niveau', + 'add_threshold' => 'Ajouter un seuil', + + // Aides contextuelles + 'levels_hint' => 'Minimum 2 niveaux. La valeur doit être un entier unique.', + 'thresholds_hint' => 'Le dernier seuil n\'a pas de borne supérieure (attrape-tout). Trier du score le plus bas au plus élevé.', + + // Champs du formulaire + 'fields' => [ + 'name' => 'Nom de la configuration', + 'formula' => 'Formule de calcul', + 'levels' => 'Niveaux', + 'thresholds' => 'Seuils de classification', + 'value' => 'Valeur', + 'label' => 'Libellé', + 'description' => 'Description', + 'level_key' => 'Clé interne', + 'score_max' => 'Score max', + 'color' => 'Couleur', + ], + + // Couleurs disponibles pour les seuils + 'colors' => [ + 'success' => 'Vert', + 'warning' => 'Orange', + 'danger' => 'Rouge', + 'alert' => 'Rouge foncé', + 'info' => 'Bleu', + 'secondary' => 'Gris', + ], + + // Formules disponibles (libellés) + 'formulas' => [ + 'probability_x_impact' => 'Probabilité × Impact', + 'likelihood_x_impact' => 'Vraisemblance × Impact (BSI 200-3)', + 'additive' => 'Probabilité + Impact', + 'max_pi' => 'max(Probabilité, Impact)', + ], + ], ]; diff --git a/resources/lang/fr/menu.php b/resources/lang/fr/menu.php index 35be77ae..bf5e4835 100644 --- a/resources/lang/fr/menu.php +++ b/resources/lang/fr/menu.php @@ -17,11 +17,14 @@ 'title' => 'Configuration', 'users' => 'Utilisateurs', 'groups' => 'Groupes', + 'scoring' => 'Classification', 'reports' => 'Rapports', 'import' => "Import", 'documents' => 'Documents', 'notifications' => 'Notifications' ], 'test' => 'Vous êtes en environnement de test – Les données sont fictives', - 'logout' => 'Quitter' + 'logout' => 'Quitter', + 'risks' => 'Matrice des risques', + 'exceptions' => 'Exceptions', ]; diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index 18b2888a..155adf0a 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -44,7 +44,7 @@ -@if (!app()->environment('production')) +@if (Config::get('app.test'))
{{ trans('menu.test') }} diff --git a/resources/views/controls/edit.blade.php b/resources/views/controls/edit.blade.php index 04ca71aa..99fe0a88 100644 --- a/resources/views/controls/edit.blade.php +++ b/resources/views/controls/edit.blade.php @@ -139,7 +139,6 @@
@endforeach - + @if ($count>0) {{ $count }}   ● @else       @endif - + @@ -99,13 +99,13 @@ } ?> @endforeach - + @if ($count>0) {{ $count }}   ● @else       @endif - + @@ -118,12 +118,12 @@ } ?> @endforeach - + @if ($count>0) {{ $count }}   ● @else       @endif - + @endfor @@ -192,12 +192,12 @@ } ?> @endforeach - + @if ($count>0) {{ $count }}   ● @else       @endif - + @@ -212,12 +212,12 @@ } ?> @endforeach - + @if ($count>0) {{ $count }}   ● @else       @endif - + @@ -230,12 +230,12 @@ } ?> @endforeach - + @if ($count>0) {{ $count }}   ● @else       @endif - + @@ -306,8 +306,8 @@ ], datasets: [ { - backgroundColor: "#60a917", - borderColor: "#60a917", + backgroundColor: "#3AB87A", + borderColor: "#3AB87A", stack: 'Stack 0', data: [ -@endsection +@endsection \ No newline at end of file diff --git a/resources/views/controls/make.blade.php b/resources/views/controls/make.blade.php index 19342084..5bf5df5f 100644 --- a/resources/views/controls/make.blade.php +++ b/resources/views/controls/make.blade.php @@ -138,7 +138,6 @@
Deming - @yield('title', 'ISMS Controls Made Easy') @vite(['resources/css/app.css', 'resources/js/app.js']) @yield('styles') - @if (!app()->environment('production')) + @if (Config::get('app.test')) + + +@endsection \ No newline at end of file diff --git a/resources/views/risks/matrix.blade.php b/resources/views/risks/matrix.blade.php new file mode 100644 index 00000000..5af84750 --- /dev/null +++ b/resources/views/risks/matrix.blade.php @@ -0,0 +1,175 @@ +@extends("layout") + +@section("content") +
+ +
+ + {{-- Matrice --}} +
+
+
+ + + + + @foreach ($xAxis as $impact) + + @endforeach + + + + @foreach (array_reverse($yAxis) as $yLevel) + + + @foreach ($xAxis as $impact) + @php + $cell = $matrix[$yLevel['value']][$impact['value']] ?? []; + $count = count($cell); + $score = $yLevel['value'] * $impact['value']; + $threshold = $scoringConfig->thresholdFor($score); + $thresholdIndex = $scoringConfig->thresholdIndexFor($score); + $bgColor = $threshold['color']; + $txtColor = '#fff'; + + @endphp + + @endforeach + + @endforeach + +
+ {{ trans('cruds.risk.fields.impact') }} {{ $impact['value'] }} +
{{ $impact['label'] }} +
+ @if ($scoringConfig->usesLikelihood()) + {{ trans('cruds.risk.fields.likelihood') }} {{ $yLevel['value'] }} + @else + {{ trans('cruds.risk.fields.probability') }} {{ $yLevel['value'] }} +
{{ $yLevel['label'] }} + @endif +
0) + onclick="location.href='/risk/index?threshold={{ $thresholdIndex }}'" + data-role="hint" + data-hint-position="top" + data-hint-text="{{ collect($cell)->pluck('name')->take(5)->implode(', ') }}{{ $count > 5 ? ' …' : '' }}" + @endif> + @if ($count > 0) +
{{ $count }}
+ {{ $count === 1 ? trans('cruds.risk.singular') : trans('cruds.risk.plural') }} + @endif +
+
+ + {{-- Légende --}} +
+ @foreach ($scoringConfig->risk_thresholds as $i => $t) + @php + $prevMax = $i > 0 ? $scoringConfig->risk_thresholds[$i-1]['max'] + 1 : 1; + @endphp + + + {{ $t['label'] }} + @if ($t['max']) {{ $prevMax }}–{{ $t['max'] }} + @else > {{ $scoringConfig->risk_thresholds[$i-1]['max'] ?? 0 }} @endif + + + @endforeach +
+
+ + {{-- Répartition par statut --}} +
+ + + + + @foreach ($scoringConfig->risk_thresholds as $i => $t) + + + + + + @endforeach + + + + + + + + + + @foreach (\App\Models\Risk::STATUS_LABELS as $status => $label) + + + + + + @endforeach +
+ {{ trans('cruds.risk.fields.by_risks') }} +
+ @if(($stats['by_level'][$i] ?? 0) > 0) + + + + @endif + + {{ $stats['by_level'][$i] ?? 0 }} + + + + {{ $t['label'] }} + + +
+ @if($stats['total'] > 0) + + + + @endif + + {{ $stats['total'] }} + + + {{ trans('cruds.risk.fields.total') }} + +
+ {{ trans('cruds.risk.fields.by_status') }} +
+ @if(($stats['by_status'][$status] ?? 0) > 0) + + + + @endif + + {{ $stats['by_status'][$status] ?? 0 }} + + + + {{ $label }} + + +
+
+
+ + {{-- Navigation + + --}} + +
+
+@endsection \ No newline at end of file diff --git a/resources/views/risks/scoring/form.blade.php b/resources/views/risks/scoring/form.blade.php new file mode 100644 index 00000000..a020d01e --- /dev/null +++ b/resources/views/risks/scoring/form.blade.php @@ -0,0 +1,444 @@ +@extends("layout") + +@section("content") +
+ + @include('partials.errors') + +
+ @csrf + +
+ + {{-- ================================================================ + Section : Paramètres généraux + ================================================================ --}} + + {{-- Nom --}} +
+
+ {{ trans("cruds.risk_scoring.fields.name") }} +
+
+ +
+
+ + {{-- Formule --}} +
+
+ {{ trans("cruds.risk_scoring.fields.formula") }} +
+
+ +
+
+ +
+
+ + {{-- ================================================================ + Section : Niveaux + ================================================================ --}} + +
+ + {{-- COL GAUCHE : Probabilité (masquée si likelihood) --}} +
+
+ +  {{ trans("cruds.risk.fields.probability") }} + +  — {{ trans('cruds.risk_scoring.levels_hint') }} + +
+ + + + + + + + + + + @foreach (old('probability_levels', $probLevels) as $idx => $level) + + + + + + + @endforeach + +
{{ trans('cruds.risk_scoring.fields.value') }}{{ trans('cruds.risk_scoring.fields.label') }}{{ trans('cruds.risk_scoring.fields.description') }}
+ +
+ + {{-- COL GAUCHE (alt) : Exposition (visible si likelihood) --}} + + + {{-- COL DROITE : Impact (toujours visible) --}} +
+
+ +  {{ trans("cruds.risk.fields.impact") }} +
+ + + + + + + + + + + @foreach (old('impact_levels', $impLevels) as $idx => $level) + + + + + + + @endforeach + +
{{ trans('cruds.risk_scoring.fields.value') }}{{ trans('cruds.risk_scoring.fields.label') }}{{ trans('cruds.risk_scoring.fields.description') }}
+ +
+ +
{{-- /row niveaux --}} + + {{-- Vulnérabilité : pleine largeur, visible si likelihood --}} + + + {{-- ================================================================ + Section : Seuils de classification + ================================================================ --}} +
+
+
+ +  {{ trans("cruds.risk_scoring.fields.thresholds") }} + +  — {{ trans('cruds.risk_scoring.thresholds_hint') }} + +
+
+
+ +
+
+ + + + + + + + + + + + + @foreach (old('risk_thresholds', $thresholds) as $idx => $t) + + + + + + + + + @endforeach + +
{{ trans('cruds.risk_scoring.fields.level_key') }}{{ trans('cruds.risk_scoring.fields.label') }}{{ trans('cruds.risk_scoring.fields.score_max') }}{{ trans('cruds.risk_scoring.fields.color') }}
+ + + + + + {{ $t['label'] }} + +
+ +
+
+ + {{-- ================================================================ + Boutons d'action + ================================================================ --}} +
+
+ +   + + +  {{ trans("common.cancel") }} + +
+
+ +
{{-- /grid --}} +
+
+ + + + +@endsection \ No newline at end of file diff --git a/resources/views/risks/scoring/index.blade.php b/resources/views/risks/scoring/index.blade.php new file mode 100644 index 00000000..a2666078 --- /dev/null +++ b/resources/views/risks/scoring/index.blade.php @@ -0,0 +1,135 @@ +@extends("layout") + +@section("content") +
+ + @include('partials.errors') + +
+
+
+ +
+
+
+ + + + + + + + + + + + + + @forelse ($configs as $config) + + {{-- Indicateur actif --}} + + + {{-- Nom --}} + + + {{-- Formule --}} + + + {{-- Résumé des niveaux --}} + + + {{-- Seuils --}} + + + {{-- Actions --}} + + + @empty + + + + @endforelse + +
{{ trans("common.active") }}{{ trans("cruds.risk_scoring.fields.name") }}{{ trans("cruds.risk_scoring.fields.formula") }}{{ trans("cruds.risk_scoring.fields.levels") }}{{ trans("cruds.risk_scoring.fields.thresholds") }}
+ @if ($config->is_active) + + @endif + + {{ $config->name }} + @if ($config->is_active) +  {{ trans('common.active') }} + @endif + {{ $formulas[$config->formula]['label'] ?? $config->formula }} + @if ($config->usesLikelihood()) + + {{ trans('cruds.risk.fields.exposure') }} : + {{ count($config->exposure_levels ?? []) }} {{ trans('cruds.risk_scoring.fields.levels') }} + +
+ + {{ trans('cruds.risk.fields.vulnerability') }} : + {{ count($config->vulnerability_levels ?? []) }} {{ trans('cruds.risk_scoring.fields.levels') }} + + @else + + {{ trans('cruds.risk.fields.probability') }} : + {{ count($config->probability_levels ?? []) }} {{ trans('cruds.risk_scoring.fields.levels') }} + + @endif +
+ + {{ trans('cruds.risk.fields.impact') }} : + {{ count($config->impact_levels ?? []) }} {{ trans('cruds.risk_scoring.fields.levels') }} + +
+ @foreach ($config->risk_thresholds as $i => $t) + @php $prevMax = $i > 0 ? ($config->risk_thresholds[$i-1]['max'] ?? null) : null; @endphp + + {{ $t['label'] }} + + @endforeach + + + + + + @unless ($config->is_active) +   + {{-- Activation : POST avec CSRF --}} +
+ @csrf + +
+   + {{-- Suppression : GET, même pattern que /bob/delete/{id} --}} +
+ +
+ @endunless +
+ {{ trans('cruds.risk_scoring.empty') }} +
+
+
+@endsection diff --git a/resources/views/risks/scoring/show.blade.php b/resources/views/risks/scoring/show.blade.php new file mode 100644 index 00000000..b3d9bbc7 --- /dev/null +++ b/resources/views/risks/scoring/show.blade.php @@ -0,0 +1 @@ +name) + +@section("content") +
+ +
+ + {{-- Nom --}} +
+
+ {{ trans("cruds.risk.fields.name") }} +
+
+ {{ $risk->name }} +
+
+ + {{-- Description --}} + @if ($risk->description) +
+
+ {{ trans("cruds.risk.fields.description") }} +
+
+
{{ $risk->description }}
+
+
+ @endif + + {{-- Propriétaire --}} +
+
+ {{ trans("cruds.risk.fields.owner") }} +
+
+ {{ $risk->owner?->name ?? '—' }} +
+
+
+
+ {{ trans("cruds.risk.fields.review_frequency") }} +
+
+ {{ $risk->review_frequency }} {{ trans('common.months') }} + @if ($risk->next_review_at) +  ·  + @if ($risk->is_overdue) + {{ $risk->next_review_at->format('Y-m-d') }} +  ⚠ + @else + {{ $risk->next_review_at->format('Y-m-d') }} + @endif + @endif +
+
+ +
+
+ + {{-- Évaluation : probabilité --}} + @if (!$scoringConfig->usesLikelihood()) +
+
+ {{ trans("cruds.risk.fields.probability") }} +
+
+ @php $probThreshold = $scoringConfig->thresholdFor($risk->probability * max($scoringConfig->levelValues('impact'))); @endphp + {{ $risk->probability }} +   + {{ $scoringConfig->levelLabel('probability', $risk->probability) }} +
+ @if ($risk->probability_comment) +
+ {{ $risk->probability_comment }} +
+ @endif +
+@endif + {{-- Exposition + Vulnérabilité (formule likelihood_x_impact) --}} + @if ($scoringConfig->usesLikelihood()) +
+
+ {{ trans("cruds.risk.fields.exposure") }} +
+
+ {{ $risk->exposure ?? '—' }} +  {{ $scoringConfig->levelLabel('exposure', $risk->exposure ?? 0) }} +
+
+
+
+ {{ trans("cruds.risk.fields.vulnerability") }} +
+
+ {{ $risk->vulnerability ?? '—' }} +  {{ $scoringConfig->levelLabel('vulnerability', $risk->vulnerability ?? 0) }} +
+
+
+
+ {{ trans("cruds.risk.fields.likelihood") }} +
+
+ {{ $risk->risk_likelihood ?? '—' }} +
+
+ @endif + + {{-- Impact --}} +
+
+ {{ trans("cruds.risk.fields.impact") }} +
+
+ {{ $risk->impact }} +   + {{ $scoringConfig->levelLabel('impact', $risk->impact) }} +
+ @if ($risk->impact_comment) +
+ {{ $risk->impact_comment }} +
+ @endif +
+ +
+
+ + {{-- Score calculé --}} +
+
+ {{ trans("cruds.risk.fields.score") }} +
+
+ @php $scoreThreshold = $scoringConfig->thresholdFor($risk->risk_score); @endphp + + {{ $risk->risk_score }} + +   + {{ $scoreThreshold['label'] }} +
+
+ +
+
+ + {{-- Statut de traitement --}} +
+
+ {{ trans("cruds.risk.fields.status") }} +
+
+ + {{ \App\Models\Risk::STATUS_LABELS[$risk->status] ?? $risk->status }} + +
+ @if ($risk->status_comment) +
+ {{ $risk->status_comment }} +
+ @endif +
+ + {{-- Mesures liés --}} + @if ($risk->measures->isNotEmpty()) +
+
+ {{ trans("cruds.risk.fields.measures") }} +
+
+ @foreach ($risk->measures as $measure) + {{ $measure->name }} + @if (!$loop->last) , @endif + @endforeach +
+
+ @endif + + {{-- Plans d'action liés --}} + @if ($risk->actions->isNotEmpty()) +
+
+ {{ trans("cruds.risk.fields.action_plan") }} +
+
+ @foreach ($risk->actions as $action) + {{ $action->name }} + @if (!$loop->last) , @endif + @endforeach +
+
+ @endif + +
+
+ + {{-- Boutons d'action --}} +
+
+ + @if (Auth::User()->role === 1 || Auth::User()->role === 2) + + +  {{ trans("common.edit") }} + +   + @endif + + @if (Auth::User()->role === 1) +
+ @csrf + +
+   + + +  {{ trans("common.history") }} + +   + @endif + + + +  {{ trans("common.cancel") }} + + +
+
+ +
+
+@endsection \ No newline at end of file diff --git a/resources/views/users/edit.blade.php b/resources/views/users/edit.blade.php index dd199be0..7d84df3e 100644 --- a/resources/views/users/edit.blade.php +++ b/resources/views/users/edit.blade.php @@ -80,9 +80,9 @@
-
- - +
+ +
@endif diff --git a/resources/views/welcome.blade.php b/resources/views/welcome.blade.php index 220fea79..3155f61a 100644 --- a/resources/views/welcome.blade.php +++ b/resources/views/welcome.blade.php @@ -1,10 +1,11 @@ @extends("layout") @section("content") -
+
-
-
+
+ {{-- Domaines — navy : ancre structurelle --}} +

{{ $active_domains_count }} @@ -17,9 +18,9 @@ {{ trans('common.more_info') }}

-
-
-
+
+ {{-- Mesures — teal : référentiel/politique --}} +

{{ $active_measures_count }}

@@ -31,9 +32,9 @@ {{ trans('common.more_info') }}
-
-
-
+
+ {{-- Contrôles — vert foncé : conformité réalisée --}} +

{{ $controls_made_count }}

@@ -45,8 +46,10 @@ {{ trans('common.more_info') }}
-
-
+
+ + {{-- Plans d'action — ambre : vigilance/remédiation --}} +

{{ $action_plans_count }} @@ -59,6 +62,38 @@ {{ trans('common.more_info') }}

+ +
+ {{-- Risques — rouge profond : criticité --}} +
+
+

+ {{ $risks_count }} +

+
{{ trans('cruds.risk.plural') }}
+
+
+ +
+ {{ trans('common.more_info') }} +
+
+ +
+ {{-- Exceptions — violet : statut dérogatoire --}} +
+

+ {{ 0 }} +

+
{{ trans('menu.exceptions') }}
+
+
+ +
+ {{ trans('common.more_info') }} +
+
+
@@ -187,8 +222,8 @@ class="table data-table striped row-hover cell-border" ], datasets: [ { - backgroundColor: "#60a917", - borderColor: "#60a917", + backgroundColor: "#3AB87A", + borderColor: "#3AB87A", stack: 'Stack 0', data: [ group(function () { @@ -131,4 +134,27 @@ Route::get('/export/bobs', 'ControlController@export'); Route::get('/export/actions', 'ActionController@export'); Route::get('/export/users', 'UserController@export'); + Route::get('/export/risks', 'RiskController@export'); + +// --- Registre des risques --- + Route::get('/risk/index', [RiskController::class, 'index'])->name('risk.index'); + Route::get('/risk/create', [RiskController::class, 'create'])->name('risk.create'); + Route::post('/risk/store', [RiskController::class, 'store'])->name('risk.store'); + Route::get('/risk/show/{id}', [RiskController::class, 'show'])->name('risk.show'); + Route::get('/risk/edit/{id}', [RiskController::class, 'edit'])->name('risk.edit'); + Route::post('/risk/save', [RiskController::class, 'update'])->name('risk.save'); + Route::get('/risk/delete/{id}', [RiskController::class, 'destroy'])->name('risk.destroy'); + Route::get('/risk/matrix', [RiskController::class, 'matrix'])->name('risk.matrix'); + Route::get('/risk/export', [RiskController::class, 'export'])->name('risk.export'); + +// --- Configuration du scoring (Admin uniquement) --- + Route::get('/risk/scoring', [RiskScoringConfigController::class, 'index'])->name('risk.scoring.index'); + Route::get('/risk/scoring/create', [RiskScoringConfigController::class, 'create'])->name('risk.scoring.create'); + Route::post('/risk/scoring/store', [RiskScoringConfigController::class, 'store'])->name('risk.scoring.store'); + Route::get('/risk/scoring/{id}/edit', [RiskScoringConfigController::class, 'edit'])->name('risk.scoring.edit'); + Route::post('/risk/scoring/{id}/save', [RiskScoringConfigController::class, 'update'])->name('risk.scoring.update'); + Route::post('/risk/scoring/{id}/activate', [RiskScoringConfigController::class, 'activate'])->name('risk.scoring.activate'); + Route::get('/risk/scoring/{id}/delete', [RiskScoringConfigController::class, 'destroy'])->name('risk.scoring.destroy'); + + });