A multilingual (EN/FR) regulatory maturity assessment web application. Token-based access with no user accounts required.
Author: Julien Mousqueton — Field CISO @ Cohesity Repository: github.com/JMousqueton/cyber-maturity
- Multiple regulatory frameworks out of the box (extensible via JSON):
- NIS2 — 10 domains, 50 questions covering all Article 21 requirements (Art. 21.2.a–j)
- DORA — 8 domains, 40 questions
- Any custom framework — drop a JSON file in
frameworks/
- Maturity scoring on a 0–5 scale with five levels: Initial, Developing, Defined, Managed, Optimized
- Sector benchmark — compares domain scores against anonymised peer organisations (requires ≥ 3 completed assessments)
- Four export formats:
- Full PDF report (detailed, all domains and recommendations)
- Executive Summary PDF (1–2 pages, top 5 recommendations, radar chart)
- Action Plan Excel (colour-coded, Owner / Target Date / Status columns)
- CSV raw data export
- Multilingual (English / French) — language follows the session
- Anonymous mode — full assessment without email; user saves their access URL
- AJAX auto-save — answers persisted immediately, no data lost on browser close
- Admin dashboard at
/admin:- Session-based login with two roles:
admin(full access) andauditor(read-only) - Session statistics, filterable session table, session deletion
- Session detail with radar chart and recommendations
- Bulk CSV export and token email resend
- Framework management: upload / download / delete / enable-disable JSON files
- User management: create, edit, delete admin users
- Audit log: every admin action is recorded with user, timestamp and IP
- Session-based login with two roles:
- Python 3.11+
- pip
WeasyPrint needs native system libraries. Install them before pip install -r requirements.txt.
Ubuntu / Debian:
sudo apt-get install -y \
python3-dev python3-pip python3-cffi \
libcairo2 \
libpango-1.0-0 libpangocairo-1.0-0 libpangoft2-1.0-0 \
libharfbuzz0b \
libgdk-pixbuf2.0-0 \
libffi-dev \
shared-mime-info \
fonts-liberationmacOS:
brew install pango libffi gdk-pixbuf libxml2 libxsltVerify WeasyPrint can load:
python3 -c "from weasyprint import HTML; print('OK')"If this prints OK, PDF export will work. If it raises an OSError about a missing .so, re-check the system packages above.
# 1. Clone the repository
git clone https://github.com/JMousqueton/cyber-maturity.git
cd cyber-maturity
# 2. Create and activate a virtual environment
python3 -m venv venv
source venv/bin/activate # macOS/Linux
venv\Scripts\activate # Windows
# 3. Install Python dependencies
pip install -r requirements.txt
# 4. Create your environment file
cp .env.example .env
# Edit .env with your settings (see Environment Variables below)
# 5. Apply database migrations
flask db upgrade
# 6. Start the application
python run.pyThe application will be available at http://localhost:5000.
The first admin user is created automatically from ADMIN_USERNAME / ADMIN_PASSWORD in .env.
| Variable | Default | Description |
|---|---|---|
FLASK_ENV |
development |
development or production |
SECRET_KEY |
(required) | Flask session secret key |
DATABASE_URL |
sqlite:///instance/maturity.db |
SQLAlchemy database URL |
MAIL_SERVER |
localhost |
SMTP server hostname |
MAIL_PORT |
25 |
SMTP port |
MAIL_USE_TLS |
False |
Enable TLS |
MAIL_USERNAME |
SMTP username | |
MAIL_PASSWORD |
SMTP password | |
MAIL_DEFAULT_SENDER |
noreply@example.com |
From address for emails |
ADMIN_USERNAME |
admin |
Initial admin username (seeded on first run) |
ADMIN_PASSWORD |
change-me |
Initial admin password (seeded on first run) |
TOKEN_EXPIRY_DAYS |
30 |
Assessment token validity in days |
BASE_URL |
http://localhost:5000 |
Public URL for tokenized links |
DEFAULT_LANGUAGE |
en |
Default language (en or fr) |
PORT |
5000 |
Port to listen on |
This project uses Flask-Migrate (Alembic) for schema management.
# Apply all pending migrations (run after every git pull)
flask db upgrade
# After modifying a model — generate a new migration
flask db migrate -m "describe your change"
flask db upgrade
# Roll back one version
flask db downgrade
# Mark an existing database as current without running migrations
flask db stamp headAccess at /admin/login.
The first user is seeded from .env on startup. Additional users can be managed in the Users tab (admin role only).
| Role | Permissions |
|---|---|
admin |
Full access — manage sessions, frameworks, users |
auditor |
Read-only — view sessions, frameworks, audit log |
Resetting a forgotten password:
source venv/bin/activate
python3 - <<'EOF'
from run import app
from app import db
from app.models import AdminUser
with app.app_context():
user = AdminUser.query.filter_by(username="admin").first()
user.set_password("new-password")
db.session.commit()
print("Password reset.")
EOFResponses are collected on a 0–4 internal scale (five maturity levels per question). Domain and global scores are displayed on a 0–5 scale (× 1.25).
| Score range | Maturity level |
|---|---|
| 0.0 – 1.24 | Initial |
| 1.25 – 2.49 | Developing |
| 2.50 – 3.74 | Defined |
| 3.75 – 4.99 | Managed |
| 5.0 | Optimized |
Recommendations are generated for every question scoring below 3 (0–5 scale), sorted by priority.
When at least 3 completed assessments exist for the same framework and sector, the results page shows:
- An orange dashed overlay on the radar chart
- A "Sector Average" column in the domain scores table
- Benchmark data included in the Executive Summary PDF
If fewer than 3 peer sessions exist, a note is displayed explaining that data is insufficient.
Add frameworks via the admin UI (Frameworks tab → Upload), or manually by placing a .json file in frameworks/. No restart required.
{
"id": "my_framework",
"version": "1.0",
"enabled": true,
"translations": {
"en": { "name": "My Framework", "description": "..." },
"fr": { "name": "Mon référentiel", "description": "..." }
},
"domains": [
{
"id": "domain_id",
"weight": 0.20,
"translations": {
"en": { "label": "Domain Label", "description": "..." },
"fr": { "label": "Libellé du domaine", "description": "..." }
},
"questions": [
{
"id": "q_001",
"article_ref": "Art. X.Y",
"translations": {
"en": { "text": "Question text?", "help": "Help text." },
"fr": { "text": "Texte de la question ?", "help": "Texte d'aide." }
},
"maturity_levels": {
"0": { "en": "None", "fr": "Inexistant" },
"1": { "en": "Initial", "fr": "Initial" },
"2": { "en": "Developing", "fr": "En développement" },
"3": { "en": "Defined", "fr": "Défini" },
"4": { "en": "Optimized", "fr": "Optimisé" }
},
"recommendation": {
"en": "Recommendation text. Ref: Art. X.Y",
"fr": "Texte de la recommandation. Réf : Art. X.Y"
},
"shared_with": []
}
]
}
]
}Rules:
- Domain weights must sum to 1.0
enabled: trueis required — set tofalseto hide from the survey without deletingshared_withaccepts other framework IDs (e.g.["dora"]) to display cross-framework badges
A minimal one-question-per-domain example is available in frameworks/sample.json.
cyber-maturity/
├── app/
│ ├── __init__.py # Flask app factory, Jinja2 filters
│ ├── models.py # SQLAlchemy models (sessions, results, admin users, audit log)
│ ├── routes/
│ │ ├── assessment.py # User-facing routes + PDF/CSV/Excel exports
│ │ ├── admin.py # Admin dashboard + framework + user management
│ │ └── auth.py # Token, language, session start
│ ├── services/
│ │ ├── scoring.py # Maturity scoring engine (0–5 scale)
│ │ ├── email.py # Email service
│ │ ├── pdf.py # PDF (WeasyPrint) + Excel (openpyxl) generation
│ │ ├── i18n.py # Translation helper
│ │ └── framework.py # JSON framework loader + validator
│ ├── templates/
│ │ ├── base.html # Base layout (navbar, footer, about modal)
│ │ ├── pdf_report.html # Full PDF template
│ │ ├── pdf_executive.html # Executive summary PDF template
│ │ └── admin/ # Admin dashboard templates (login, dashboard, users, audit)
│ ├── translations/
│ │ ├── en.json # English strings
│ │ └── fr.json # French strings
│ └── static/ # CSS, JS, favicons
├── frameworks/ # Framework JSON files (gitignored: sample*.json, test*.json)
│ ├── nis2.json
│ └── dora.json
├── migrations/ # Alembic migration versions
├── tests/
│ └── test_scoring.py
├── instance/
│ └── maturity.db # SQLite database (auto-created, gitignored)
├── .env.example
├── config.py
├── requirements.txt
└── run.py
- Token-based access — every session gets a UUID4 token; no user accounts needed.
- Anonymous mode — full assessment without email; the user must manually save the URL.
- AJAX auto-save — each answer saved immediately via
POST /assessment/<token>/answer. - Framework extensibility — drop a
.jsonfile inframeworks/, toggle enabled/disabled from the admin UI. - Scoring — weighted average per domain → weighted global score on a 0–5 scale.
- Benchmark — sector peer comparison using anonymised completed sessions (minimum 3).
- Cross-framework badges — questions with
shared_withdisplay a pill badge on the assessment page. - Admin auth — session-based login form; two roles (admin / auditor); all actions recorded in audit log.
- Migrations — Flask-Migrate (Alembic); run
flask db upgradeafter every deployment.
# Generate a strong secret key
export SECRET_KEY=$(python3 -c "import secrets; print(secrets.token_hex(32))")
# Set environment
export FLASK_ENV=production
# Apply migrations
flask db upgrade
# Start with gunicorn
pip install gunicorn
gunicorn -w 4 -b 0.0.0.0:8000 "run:app"Use a reverse proxy (nginx/caddy) in front of gunicorn. SESSION_COOKIE_SECURE=True is enabled automatically in ProductionConfig.
pytest tests/ -v
# With coverage
pip install pytest-cov
pytest tests/ --cov=app/services/scoring