diff --git a/.claude/HISTORY.md b/.claude/HISTORY.md index 302c5888..1f503002 100644 --- a/.claude/HISTORY.md +++ b/.claude/HISTORY.md @@ -3669,3 +3669,30 @@ git checkout HEAD -- index.html - `/srv/streamlit/apps/alerte_depot/app.py`: Full whistleblower platform - `/srv/secubox/mesh/alertes-chain.json`: Audit blockchain - `/usr/sbin/alerte-depot-cron`: Deadline monitor + +37. **VoIP Voice Recorder Configuration (2026-02-25)** + - **Voice Recorder Mode:** + - All incoming calls sent directly to voicemail + - Automatic email notification with WAV attachment + - OVH SIP trunk integration for official number + - **Email Integration:** + - Created voicemail@secubox.in account in mailserver + - Configured msmtp in VoIP container + - Email subject template with caller ID + - **Files:** + - `/srv/lxc/voip/rootfs/etc/asterisk/voicemail.conf` + - `/srv/lxc/voip/rootfs/etc/asterisk/extensions.conf` + - `/srv/lxc/voip/rootfs/etc/msmtprc` + +38. **ALERTE.DEPOT Authentication Fix (2026-02-25)** + - **Container HTTP Auth:** + - Streamlit container cannot access host `ubus` directly + - Changed authenticate_admin() from subprocess to HTTP API + - Uses http://127.0.0.1/ubus JSON-RPC endpoint + - **SecuBox Users Integration:** + - Admin login validates via luci.secubox-users RPCD + - Session tokens stored in /tmp/secubox-sessions/ + - 24-hour token expiry + - **Test Credentials:** + - gk2 / Gk2Test2026 + - ragondin / Secubox@2026 diff --git a/package/secubox/secubox-app-streamlit/files/srv/streamlit/apps/alerte_depot/README.md b/package/secubox/secubox-app-streamlit/files/srv/streamlit/apps/alerte_depot/README.md new file mode 100644 index 00000000..3a707384 --- /dev/null +++ b/package/secubox/secubox-app-streamlit/files/srv/streamlit/apps/alerte_depot/README.md @@ -0,0 +1,107 @@ +# 🚨 ALERTE.DEPOT β€” Formulaire Citoyen de Signalement + +> Dispositif KISS de dΓ©pΓ΄t d'alertes pour non-professionnels +> Conforme **Loi Waserman nΒ°2022-401** Β· **Directive UE 2019/1937** +> IntΓ©gration **Gitea** native Β· QR Code d'attestation Β· RIB SEPA + +--- + +## πŸš€ Installation rapide + +```bash +# Cloner / tΓ©lΓ©charger le projet +cd alerte_streamlit + +# Installer les dΓ©pendances +pip install -r requirements.txt + +# Configurer les secrets Gitea +cp .streamlit/secrets.toml.example .streamlit/secrets.toml +nano .streamlit/secrets.toml # renseigner vos valeurs + +# Lancer l'application +streamlit run app.py +``` + +L'application sera disponible sur `http://localhost:8501` + +--- + +## βš™οΈ Configuration Gitea + +### CrΓ©er un token API dans Gitea +1. Connectez-vous Γ  votre instance Gitea +2. `ParamΓ¨tres` β†’ `Applications` β†’ `GΓ©nΓ©rer un token` +3. Permissions nΓ©cessaires : **Issues** (write) + **Contents** (write) +4. Copiez le token dans `.streamlit/secrets.toml` + +### CrΓ©er le dΓ©pΓ΄t rΓ©cepteur +``` +gitea.votredomaine.fr//alertes +``` +Le dΓ©pΓ΄t peut Γͺtre **privΓ©** β€” seul l'accΓ¨s API est nΓ©cessaire. + +--- + +## πŸ—‚οΈ Structure des signalements dans Gitea + +Chaque signalement crΓ©e : +- **Une Issue** : titre = `🚨 [type] gravitΓ© β€” TOKEN` +- **Un fichier Markdown** : `signalements/YYYY/MM/TOKEN.md` (optionnel) +- **Un label** : `🚨 alerte` (créé automatiquement) + +--- + +## πŸ“‹ FonctionnalitΓ©s + +| FonctionnalitΓ© | DΓ©tail | +|---|---| +| πŸ•΅οΈ **Anonymat** | 3 modes : anonyme / pseudo / identitΓ© protΓ©gΓ©e | +| 🎲 **Pseudonyme** | GΓ©nΓ©ration alΓ©atoire ou personnalisΓ© | +| πŸ“‚ **8 catΓ©gories** | Fraude, SantΓ©, Environnement, RGPD, Travail... | +| ⚑ **4 niveaux de gravitΓ©** | Faible β†’ Critique | +| πŸ“‘ **5 canaux lΓ©gaux** | Interne, AFA, DDD, Parquet, Public | +| πŸ”‘ **Token de suivi** | GΓ©nΓ©rΓ© et tΓ©lΓ©chargeable en QR code | +| πŸ“± **QR Attestation** | PNG tΓ©lΓ©chargeable, encodage ECC niveau H | +| 🏦 **QR SEPA EPC** | Virement prΓ©-rempli, montant sΓ©lectionnable | +| πŸ“„ **Export Markdown** | Signalement complet tΓ©lΓ©chargeable | +| 🌿 **Gitea** | Issues + fichiers .md dans le dΓ©pΓ΄t | + +--- + +## πŸ”’ Cadre lΓ©gal intΓ©grΓ© + +- **Loi Sapin II** (2016) : fondements du dispositif +- **Directive UE 2019/1937** : dΓ©lais (7j accusΓ©, 3 mois retour) +- **Loi Waserman nΒ°2022-401** (vigueur 01/09/2022) : + - Libre choix canal interne/externe + - Suppression dΓ©sintΓ©ressement obligatoire + - Extension aux personnes morales (facilitateurs) + +--- + +## 🏦 RIB β€” Soutien participatif + +``` +BΓ©nΓ©ficiaire : GΓ©rald Kerma +IBAN : FR76 2823 3000 0100 4454 7823 788 +BIC : REVOFRP2 +Banque : Revolut Bank UAB β€” 10 av. KlΓ©ber, 75116 Paris +``` + +--- + +## πŸ› DΓ©ploiement production + +```bash +# Avec Docker +docker build -t alerte-depot . +docker run -p 8501:8501 -v $(pwd)/.streamlit:/app/.streamlit alerte-depot + +# Avec systemd (Linux) +# Voir alerte.service +``` + +--- + +*CyberMind.FR β€” Usage Γ©ducatif et dΓ©monstratif* diff --git a/package/secubox/secubox-app-streamlit/files/srv/streamlit/apps/alerte_depot/app.py b/package/secubox/secubox-app-streamlit/files/srv/streamlit/apps/alerte_depot/app.py new file mode 100644 index 00000000..9f063120 --- /dev/null +++ b/package/secubox/secubox-app-streamlit/files/srv/streamlit/apps/alerte_depot/app.py @@ -0,0 +1,1041 @@ +# -*- coding: utf-8 -*- +""" +ALERTE.DEPOT - Plateforme de Signalement Anonyme +CyberMind.FR | Conforme Loi Waserman nΒ°2022-401 + +Features: +- Anonymous submission (no auth required) +- Token-based tracking portal +- Admin dashboard (SecuBox Users auth) +""" + +import streamlit as st +import os +import requests +import qrcode +import json +import random +import string +import hashlib +import subprocess +from datetime import datetime +from io import BytesIO +import base64 +import fcntl + +# ─── AUDIT TRAIL (BLOCKCHAIN) ─────────────────────────────────────────────────── +AUDIT_CHAIN_FILE = "/srv/secubox/mesh/alertes-chain.json" + +def add_audit_entry(action: str, token_hash: str, data: dict = None) -> bool: + """Add an immutable entry to the audit chain""" + try: + entry = { + "timestamp": datetime.utcnow().isoformat() + "Z", + "action": action, + "token_hash": token_hash[:16] + "...", + "data": data or {}, + "prev_hash": "" + } + + # Read existing chain + chain = {"chain": [], "version": "1.0"} + if os.path.exists(AUDIT_CHAIN_FILE): + with open(AUDIT_CHAIN_FILE, 'r') as f: + fcntl.flock(f, fcntl.LOCK_SH) + chain = json.load(f) + fcntl.flock(f, fcntl.LOCK_UN) + + # Calculate prev_hash from last entry + if chain.get("chain"): + last_entry = json.dumps(chain["chain"][-1], sort_keys=True) + entry["prev_hash"] = hashlib.sha256(last_entry.encode()).hexdigest()[:16] + + # Add entry hash + entry["hash"] = hashlib.sha256( + json.dumps(entry, sort_keys=True).encode() + ).hexdigest()[:16] + + chain["chain"].append(entry) + + # Write back + os.makedirs(os.path.dirname(AUDIT_CHAIN_FILE), exist_ok=True) + with open(AUDIT_CHAIN_FILE, 'w') as f: + fcntl.flock(f, fcntl.LOCK_EX) + json.dump(chain, f, indent=2) + fcntl.flock(f, fcntl.LOCK_UN) + + return True + except Exception: + return False + +# ─── CONFIG PAGE ─────────────────────────────────────────────────────────────── +st.set_page_config( + page_title="ALERTE.DEPOT", + page_icon="🚨", + layout="centered", + initial_sidebar_state="expanded", +) + +# ─── CSS ──────────────────────────────────────────────────────────────────────── +st.markdown(""" + +""", unsafe_allow_html=True) + +# ─── CONSTANTS ────────────────────────────────────────────────────────────────── +CATEGORIES = { + "corruption": ("πŸ’° Corruption", "Pots-de-vin, favoritisme, conflits d'intΓ©rΓͺts"), + "fraude": ("πŸ’Έ Fraude", "DΓ©tournement de fonds, faux documents"), + "securite": ("πŸ₯ SΓ©curitΓ©", "Risques pour la santΓ©/sΓ©curitΓ© publique"), + "discrimination": ("βš–οΈ Discrimination", "Traitement inΓ©gal basΓ© sur critΓ¨res protΓ©gΓ©s"), + "harcelement": ("🚫 HarcΓ¨lement", "HarcΓ¨lement moral ou sexuel"), + "environnement": ("🌿 Environnement", "Pollution, violations rΓ©glementaires"), + "autre": ("πŸ“‹ Autre", "Autres signalements d'intΓ©rΓͺt public"), +} + +SEVERITY_LEVELS = { + "low": ("πŸ”΅", "Faible", "Impact limitΓ©"), + "medium": ("🟑", "ModΓ©rΓ©", "Impact significatif"), + "high": ("πŸ”΄", "Γ‰levΓ©", "IntΓ©rΓͺt public majeur"), + "critical": ("πŸ†˜", "Critique", "Danger immΓ©diat"), +} + +STATUS_LABELS = { + "received": ("πŸ“₯ ReΓ§u", "status-received"), + "validated": ("βœ… ValidΓ©", "status-validated"), + "investigating": ("πŸ” En cours", "status-investigating"), + "resolved": ("βœ”οΈ RΓ©solu", "status-resolved"), + "rejected": ("❌ RejetΓ©", "status-rejected"), +} + +PSEUDOS_ADJ = ["Brave","Discret","Vigilant","Courageux","Sage","Libre","Γ‰veillΓ©","Lucide","Ferme","Vif"] +PSEUDOS_NOM = ["Colombe","Lanterne","Sentinelle","Phare","Boussole","Flambeau","Bouclier","TΓ©moin","Voix","Γ‰claireur"] + +# ─── GITEA CONFIG ─────────────────────────────────────────────────────────────── +try: + GITEA_URL = st.secrets.get("gitea", {}).get("url", "") or os.environ.get("GITEA_URL", "") + GITEA_TOKEN = st.secrets.get("gitea", {}).get("token", "") or os.environ.get("GITEA_TOKEN", "") + GITEA_OWNER = st.secrets.get("gitea", {}).get("owner", "") or os.environ.get("GITEA_OWNER", "") + GITEA_REPO = st.secrets.get("gitea", {}).get("repo", "") or os.environ.get("GITEA_REPO", "alertes-depot") + GITEA_CONFIGURED = bool(GITEA_URL and GITEA_TOKEN) +except Exception: + GITEA_URL = GITEA_TOKEN = GITEA_OWNER = GITEA_REPO = "" + GITEA_CONFIGURED = False + +# ─── HELPER FUNCTIONS ─────────────────────────────────────────────────────────── +def gen_pseudo(): + return f"{random.choice(PSEUDOS_ADJ)}_{random.choice(PSEUDOS_NOM)}_{random.randint(100,9999)}" + +def gen_token(): + parts = [''.join(random.choices(string.ascii_uppercase + string.digits, k=4)) for _ in range(4)] + return '-'.join(parts) + +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode()).hexdigest() + +def make_qr(data: str) -> BytesIO: + qr = qrcode.QRCode(version=2, box_size=6, border=3, + error_correction=qrcode.constants.ERROR_CORRECT_H) + qr.add_data(data) + qr.make(fit=True) + img = qr.make_image(fill_color="#065f46", back_color="white") + buf = BytesIO() + img.save(buf, format="PNG") + buf.seek(0) + return buf + +# ─── GITEA CLIENT ─────────────────────────────────────────────────────────────── +class GiteaClient: + def __init__(self, base_url: str, token: str, owner: str, repo: str): + self.base_url = base_url.rstrip('/') + self.headers = {"Authorization": f"token {token}", "Content-Type": "application/json"} + self.owner = owner + self.repo = repo + + def test_connection(self) -> tuple: + try: + r = requests.get( + f"{self.base_url}/api/v1/repos/{self.owner}/{self.repo}", + headers=self.headers, timeout=5 + ) + if r.status_code == 200: + return True, f"Connected to {r.json().get('full_name','?')}" + return False, f"Error {r.status_code}" + except Exception as e: + return False, str(e) + + def create_issue(self, title: str, body: str, labels: list = None) -> tuple: + payload = {"title": title, "body": body} + if labels: + payload["labels"] = labels + try: + r = requests.post( + f"{self.base_url}/api/v1/repos/{self.owner}/{self.repo}/issues", + headers=self.headers, json=payload, timeout=10 + ) + if r.status_code in (200, 201): + data = r.json() + return True, data.get("html_url", ""), data.get("number", 0) + return False, f"Error {r.status_code}: {r.text[:200]}", 0 + except Exception as e: + return False, str(e), 0 + + def search_issues(self, query: str) -> list: + """Search issues by title containing the query (token)""" + try: + r = requests.get( + f"{self.base_url}/api/v1/repos/{self.owner}/{self.repo}/issues", + headers=self.headers, + params={"state": "all", "q": query}, + timeout=10 + ) + if r.status_code == 200: + return r.json() + return [] + except Exception: + return [] + + def get_issue(self, number: int) -> dict: + """Get single issue by number""" + try: + r = requests.get( + f"{self.base_url}/api/v1/repos/{self.owner}/{self.repo}/issues/{number}", + headers=self.headers, timeout=10 + ) + if r.status_code == 200: + return r.json() + return {} + except Exception: + return {} + + def get_issue_comments(self, number: int) -> list: + """Get comments on an issue""" + try: + r = requests.get( + f"{self.base_url}/api/v1/repos/{self.owner}/{self.repo}/issues/{number}/comments", + headers=self.headers, timeout=10 + ) + if r.status_code == 200: + return r.json() + return [] + except Exception: + return [] + + def add_comment(self, number: int, body: str) -> bool: + """Add comment to issue""" + try: + r = requests.post( + f"{self.base_url}/api/v1/repos/{self.owner}/{self.repo}/issues/{number}/comments", + headers=self.headers, + json={"body": body}, + timeout=10 + ) + return r.status_code in (200, 201) + except Exception: + return False + + def update_issue_labels(self, number: int, labels: list) -> bool: + """Update issue labels""" + try: + r = requests.patch( + f"{self.base_url}/api/v1/repos/{self.owner}/{self.repo}/issues/{number}", + headers=self.headers, + json={"labels": labels}, + timeout=10 + ) + return r.status_code == 200 + except Exception: + return False + + def list_all_issues(self, state: str = "open") -> list: + """List all issues""" + try: + r = requests.get( + f"{self.base_url}/api/v1/repos/{self.owner}/{self.repo}/issues", + headers=self.headers, + params={"state": state, "limit": 100}, + timeout=10 + ) + if r.status_code == 200: + return r.json() + return [] + except Exception: + return [] + + def get_or_create_label(self, name: str, color: str) -> int: + r = requests.get( + f"{self.base_url}/api/v1/repos/{self.owner}/{self.repo}/labels", + headers=self.headers, timeout=5 + ) + if r.status_code == 200: + for lbl in r.json(): + if lbl["name"] == name: + return lbl["id"] + rc = requests.post( + f"{self.base_url}/api/v1/repos/{self.owner}/{self.repo}/labels", + headers=self.headers, + json={"name": name, "color": color}, timeout=5 + ) + if rc.status_code in (200, 201): + return rc.json()["id"] + return None + + def push_file(self, path: str, content: str, message: str) -> tuple: + encoded = base64.b64encode(content.encode()).decode() + r_get = requests.get( + f"{self.base_url}/api/v1/repos/{self.owner}/{self.repo}/contents/{path}", + headers=self.headers, timeout=5 + ) + payload = {"message": message, "content": encoded} + if r_get.status_code == 200: + payload["sha"] = r_get.json().get("sha", "") + try: + r = requests.post( + f"{self.base_url}/api/v1/repos/{self.owner}/{self.repo}/contents/{path}", + headers=self.headers, json=payload, timeout=10 + ) + return r.status_code in (200, 201), "" + except Exception as e: + return False, str(e) + +# ─── SECUBOX USERS AUTH ───────────────────────────────────────────────────────── +def authenticate_admin(username: str, password: str) -> tuple: + """Authenticate via SecuBox Users RPCD API (HTTP)""" + try: + # Use HTTP API to call RPCD (works inside container) + payload = { + "jsonrpc": "2.0", + "id": 1, + "method": "call", + "params": [ + "00000000000000000000000000000000", # Anonymous session + "luci.secubox-users", + "authenticate", + {"username": username, "password": password} + ] + } + resp = requests.post("http://127.0.0.1/ubus", json=payload, timeout=10) + if resp.status_code == 200: + data = resp.json() + # RPCD returns {"result": [0, {...}]} on success + if data.get("result") and len(data["result"]) > 1: + result = data["result"][1] + if result.get("success"): + return True, result.get("username", username), result.get("token", "") + return False, "", "" + except Exception as e: + return False, "", str(e) + + +# ─── SESSION STATE ────────────────────────────────────────────────────────────── +def init_state(): + defaults = { + "mode": "submit", # submit, track, admin + "step": 1, + "pseudo": gen_pseudo(), + "token": gen_token(), + "anon_mode": "pseudo", + "category": "corruption", + "severity": "medium", + "description": "", + "period": "", + "location": "", + "evidence_types": [], + "contact_pseudo": "", + "submitted": False, + "gitea_url": None, + "issue_number": 0, + # Tracking + "track_token": "", + "track_result": None, + # Admin + "admin_logged_in": False, + "admin_user": "", + "admin_token": "", + } + for k, v in defaults.items(): + if k not in st.session_state: + st.session_state[k] = v + +init_state() + +# ─── SIDEBAR ──────────────────────────────────────────────────────────────────── +with st.sidebar: + st.markdown("### 🚨 ALERTE.DEPOT") + st.markdown("---") + + # Mode selector + mode = st.radio( + "Mode", + ["πŸ†• Nouveau signalement", "πŸ” Suivre mon dossier", "πŸ” Administration"], + index=["submit", "track", "admin"].index(st.session_state.mode), + label_visibility="collapsed" + ) + + mode_map = { + "πŸ†• Nouveau signalement": "submit", + "πŸ” Suivre mon dossier": "track", + "πŸ” Administration": "admin" + } + if mode_map.get(mode) != st.session_state.mode: + st.session_state.mode = mode_map[mode] + st.rerun() + + st.markdown("---") + + if GITEA_CONFIGURED: + st.success(f"βœ… Gitea: {GITEA_OWNER}/{GITEA_REPO}") + else: + st.warning("⚠️ Gitea non configurΓ©") + + st.markdown("---") + st.markdown("### πŸ§… AccΓ¨s Tor") + st.markdown(""" +**Maximum d'anonymat :** +``` +i7j46m67zvdksfhddbq273yydpuo5xvewsl2fjl5zlycjyo4qelysnid.onion +``` +*Utilisez Tor Browser* + """) + + st.markdown("---") + st.markdown("### βš–οΈ Cadre lΓ©gal") + st.markdown(""" +**Loi Waserman** nΒ°2022-401 +Anonymat garanti Β· RGPD + +**Directive UE** 2019/1937 +AccusΓ© : **7 jours** +Retour : **3 mois** + """) + st.markdown("---") + st.caption("πŸ”’ CyberMind.FR") + +# ═══════════════════════════════════════════════════════════════════════════════ +# MODE: SUBMIT (Anonymous Submission) +# ═══════════════════════════════════════════════════════════════════════════════ +if st.session_state.mode == "submit": + + st.markdown("# 🚨 DΓ©poser une alerte") + st.markdown("**Simple, anonyme, protΓ©gΓ© par la loi.** Pas d'inscription requise.") + + # Progress bar + steps_total = 4 + bar_html = '
' + for i in range(1, steps_total + 1): + cls = "done" if i < st.session_state.step else ("active" if i == st.session_state.step else "step") + bar_html += f'
' + bar_html += '
' + st.markdown(bar_html, unsafe_allow_html=True) + + step_labels = ["β‘  IdentitΓ©", "β‘‘ CatΓ©gorie", "β‘’ Description", "β‘£ Envoi"] + st.markdown(f"**Γ‰tape {st.session_state.step} / {steps_total}** β€” {step_labels[st.session_state.step-1]}") + st.markdown("---") + + # STEP 1: Identity + if st.session_state.step == 1: + st.markdown(""" +
+
πŸ‘€ Comment voulez-vous dΓ©poser ?
+
Vous Γͺtes protΓ©gΓ©(e) dans tous les cas par la loi.
+
+""", unsafe_allow_html=True) + + col1, col2 = st.columns(2) + with col1: + if st.button("πŸ•΅οΈ **Anonyme**\nPseudo auto-gΓ©nΓ©rΓ©", + use_container_width=True, + type="primary" if st.session_state.anon_mode == "anon" else "secondary"): + st.session_state.anon_mode = "anon" + st.session_state.pseudo = gen_pseudo() + st.rerun() + with col2: + if st.button("🎭 **Pseudonyme**\nVotre alias personnalisΓ©", + use_container_width=True, + type="primary" if st.session_state.anon_mode == "pseudo" else "secondary"): + st.session_state.anon_mode = "pseudo" + st.rerun() + + st.markdown("#### Votre pseudonyme") + c1, c2 = st.columns([3, 1]) + with c1: + st.session_state.pseudo = st.text_input( + "Alias", value=st.session_state.pseudo, label_visibility="collapsed" + ) + with c2: + if st.button("🎲 Nouveau"): + st.session_state.pseudo = gen_pseudo() + st.rerun() + + st.markdown(""" +
+ℹ️ Ce pseudonyme sera votre identifiant. Notez-le prΓ©cieusement. +
+""", unsafe_allow_html=True) + + if st.button("➑️ Suivant", type="primary", use_container_width=True): + if len(st.session_state.pseudo) >= 3: + st.session_state.step = 2 + st.rerun() + else: + st.error("Pseudonyme trop court (min 3 caractères)") + + # STEP 2: Category + elif st.session_state.step == 2: + st.markdown(""" +
+
🏷️ Type de signalement
+
+""", unsafe_allow_html=True) + + cat_options = [f"{v[0]} β€” {k}" for k, v in CATEGORIES.items()] + cat_selected = st.selectbox("CatΓ©gorie", cat_options) + st.session_state.category = cat_selected.split(" β€” ")[1] if " β€” " in cat_selected else "autre" + + st.markdown("#### ⚑ GravitΓ©") + sev_cols = st.columns(4) + for i, (code, (emoji, label, desc)) in enumerate(SEVERITY_LEVELS.items()): + with sev_cols[i]: + is_active = st.session_state.severity == code + if st.button(f"{emoji}\n{label}", key=f"sev_{code}", + type="primary" if is_active else "secondary", + use_container_width=True): + st.session_state.severity = code + st.rerun() + + c1, c2 = st.columns(2) + with c1: + if st.button("⬅️ Retour", use_container_width=True): + st.session_state.step = 1 + st.rerun() + with c2: + if st.button("➑️ Suivant", type="primary", use_container_width=True): + st.session_state.step = 3 + st.rerun() + + # STEP 3: Description + elif st.session_state.step == 3: + st.markdown(""" +
+
πŸ“ DΓ©crivez les faits
+
Γ‰crivez simplement, comme Γ  un ami de confiance.
+
+""", unsafe_allow_html=True) + + desc = st.text_area( + "Description", + value=st.session_state.description, + height=200, + placeholder="DΓ©crivez ce que vous avez constatΓ©..." + ) + st.session_state.description = desc + + char_count = len(desc) + color = "#10b981" if char_count >= 100 else "#f59e0b" if char_count >= 50 else "#ef4444" + st.markdown(f"{char_count} caractΓ¨res", unsafe_allow_html=True) + + st.markdown("---") + c1, c2 = st.columns(2) + with c1: + st.session_state.period = st.text_input("πŸ“… Quand ?", value=st.session_state.period, + placeholder="Ex: depuis janvier 2024") + with c2: + st.session_state.location = st.text_input("πŸ“ OΓΉ ?", value=st.session_state.location, + placeholder="Ex: Lyon, France") + + c1, c2 = st.columns(2) + with c1: + if st.button("⬅️ Retour", use_container_width=True): + st.session_state.step = 2 + st.rerun() + with c2: + if st.button("➑️ Finaliser", type="primary", use_container_width=True): + if len(desc) >= 50: + st.session_state.step = 4 + st.rerun() + else: + st.error("Description trop courte (min 50 caractΓ¨res)") + + # STEP 4: Summary + Send + elif st.session_state.step == 4 and not st.session_state.submitted: + st.markdown(""" +
+
βœ… RΓ©capitulatif
+
+""", unsafe_allow_html=True) + + cat_info = CATEGORIES.get(st.session_state.category, ("πŸ“‹ Autre", "")) + sev_info = SEVERITY_LEVELS.get(st.session_state.severity, ("🟑", "ModΓ©rΓ©", "")) + + col1, col2 = st.columns(2) + with col1: + st.markdown(f"🎭 **Pseudonyme** : `{st.session_state.pseudo}`") + st.markdown(f"🏷️ **CatΓ©gorie** : {cat_info[0]}") + with col2: + st.markdown(f"⚑ **GravitΓ©** : {sev_info[0]} {sev_info[1]}") + if st.session_state.period: + st.markdown(f"πŸ“… **PΓ©riode** : {st.session_state.period}") + + st.markdown("πŸ“ **Description** :") + st.info(st.session_state.description[:300] + ("..." if len(st.session_state.description) > 300 else "")) + + consent = st.checkbox("βœ… Je dΓ©clare agir de bonne foi et avoir des motifs raisonnables.") + + c1, c2 = st.columns(2) + with c1: + if st.button("⬅️ Modifier", use_container_width=True): + st.session_state.step = 3 + st.rerun() + with c2: + send_btn = st.button("πŸš€ ENVOYER", type="primary", use_container_width=True, disabled=not consent) + + if send_btn and consent: + ts = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S UTC") + token = st.session_state.token + token_hash = hash_token(token) + + cat_info = CATEGORIES.get(st.session_state.category, ("πŸ“‹ Autre", "")) + sev_info = SEVERITY_LEVELS.get(st.session_state.severity, ("🟑", "ModΓ©rΓ©", "")) + + md_content = f"""# 🚨 Signalement #{token[:8]} + +| Champ | Valeur | +|-------|--------| +| **Token** | `{token}` | +| **Token Hash** | `{token_hash[:16]}...` | +| **Pseudonyme** | `{st.session_state.pseudo}` | +| **Date** | {ts} | +| **CatΓ©gorie** | {cat_info[0]} | +| **GravitΓ©** | {sev_info[0]} {sev_info[1]} | +| **PΓ©riode** | {st.session_state.period or 'Non prΓ©cisΓ©'} | +| **Lieu** | {st.session_state.location or 'Non prΓ©cisΓ©'} | +| **Statut** | πŸ“₯ REΓ‡U | + +## Description + +{st.session_state.description} + +--- +*Loi Waserman nΒ°2022-401 Β· Directive UE 2019/1937* +""" + + issue_title = f"🚨 [{cat_info[0]}] {sev_info[0]} {sev_info[1]} β€” {token}" + + gitea_url = "" + issue_num = 0 + + if GITEA_CONFIGURED: + with st.spinner("πŸ“‘ Envoi..."): + client = GiteaClient(GITEA_URL, GITEA_TOKEN, GITEA_OWNER, GITEA_REPO) + lbl_id = client.get_or_create_label("🚨 alerte", "#e11d48") + labels = [lbl_id] if lbl_id else [] + + ok, url, num = client.create_issue(issue_title, md_content, labels) + if ok: + gitea_url = url + issue_num = num + + safe_token = token.replace('-','_') + file_path = f"signalements/{datetime.utcnow().strftime('%Y/%m')}/{safe_token}.md" + client.push_file(file_path, md_content, f"feat: signalement {token}") + + # Add audit trail entry + add_audit_entry( + action="submission", + token_hash=token_hash, + data={ + "category": st.session_state.category, + "severity": st.session_state.severity, + "issue_number": issue_num, + "status": "received" + } + ) + + st.session_state.submitted = True + st.session_state.gitea_url = gitea_url + st.session_state.issue_number = issue_num + st.session_state.submit_ts = ts + st.session_state.md_content = md_content + st.rerun() + + # STEP 4: Confirmation + elif st.session_state.step == 4 and st.session_state.submitted: + token = st.session_state.token + ts = st.session_state.get("submit_ts", "") + + st.markdown(""" +
+πŸŽ‰ Signalement dΓ©posΓ© avec succΓ¨s ! +Conservez votre token de suivi. +
+""", unsafe_allow_html=True) + + st.markdown(f'
πŸ”‘ {token}
', unsafe_allow_html=True) + + if st.session_state.gitea_url: + st.markdown(f"πŸ“‹ Issue #{st.session_state.issue_number}") + + st.caption(f"⏰ {ts} Β· πŸ‘€ {st.session_state.pseudo}") + + col_qr, col_info = st.columns([1, 2]) + with col_qr: + qr_data = f"ALERTE:{token}" + qr_buf = make_qr(qr_data) + st.image(qr_buf, caption="πŸ“± QR Code", width=160) + qr_buf.seek(0) + st.download_button("⬇️ QR Code", data=qr_buf, file_name=f"alerte-{token}.png", mime="image/png") + + with col_info: + st.markdown(f""" +| | | +|---|---| +| πŸ”‘ Token | `{token}` | +| πŸ‘€ Pseudo | `{st.session_state.pseudo}` | +| πŸ“… Date | {ts} | +| βš–οΈ Protection | Loi Waserman | +""") + + st.markdown("---") + st.markdown("#### ⏱️ DΓ©lais lΓ©gaux") + c1, c2 = st.columns(2) + with c1: + st.metric("AccusΓ© de rΓ©ception", "7 jours") + with c2: + st.metric("Retour d'information", "3 mois") + + st.markdown("---") + if st.button("πŸ”„ Nouveau signalement", use_container_width=True): + for key in ["step","pseudo","token","category","severity","description", + "period","location","submitted","gitea_url","issue_number"]: + if key in st.session_state: + del st.session_state[key] + init_state() + st.rerun() + +# ═══════════════════════════════════════════════════════════════════════════════ +# MODE: TRACK (Token-based lookup) +# ═══════════════════════════════════════════════════════════════════════════════ +elif st.session_state.mode == "track": + + st.markdown("# πŸ” Suivre mon signalement") + st.markdown("Entrez votre token de suivi pour consulter l'Γ©tat de votre dossier.") + + track_token = st.text_input( + "πŸ”‘ Token de suivi", + value=st.session_state.track_token, + placeholder="XXXX-XXXX-XXXX-XXXX" + ).strip().upper() + + if st.button("πŸ” Rechercher", type="primary", use_container_width=True): + if len(track_token) >= 8: + st.session_state.track_token = track_token + + if GITEA_CONFIGURED: + with st.spinner("Recherche en cours..."): + client = GiteaClient(GITEA_URL, GITEA_TOKEN, GITEA_OWNER, GITEA_REPO) + issues = client.search_issues(track_token) + + matching = [i for i in issues if track_token in i.get("title", "")] + + if matching: + issue = matching[0] + comments = client.get_issue_comments(issue["number"]) + st.session_state.track_result = { + "found": True, + "issue": issue, + "comments": comments + } + else: + st.session_state.track_result = {"found": False} + else: + st.error("Gitea non configurΓ©") + st.rerun() + else: + st.warning("Token trop court") + + # Display result + if st.session_state.track_result: + st.markdown("---") + + if st.session_state.track_result.get("found"): + issue = st.session_state.track_result["issue"] + comments = st.session_state.track_result.get("comments", []) + + st.markdown("### πŸ“‹ Votre dossier") + + # Extract status from labels + labels = [l.get("name", "") for l in issue.get("labels", [])] + status = "received" + for lbl in labels: + if "validΓ©" in lbl.lower(): + status = "validated" + elif "cours" in lbl.lower() or "investigating" in lbl.lower(): + status = "investigating" + elif "rΓ©solu" in lbl.lower() or "resolved" in lbl.lower(): + status = "resolved" + elif "rejetΓ©" in lbl.lower() or "rejected" in lbl.lower(): + status = "rejected" + + status_info = STATUS_LABELS.get(status, ("πŸ“₯ ReΓ§u", "status-received")) + st.markdown(f'{status_info[0]}', + unsafe_allow_html=True) + + # Issue info + created = issue.get("created_at", "")[:10] + updated = issue.get("updated_at", "")[:10] + + st.markdown(f""" +| | | +|---|---| +| πŸ“… DΓ©posΓ© | {created} | +| πŸ”„ Mis Γ  jour | {updated} | +| πŸ“ NΒ° dossier | #{issue.get("number", "?")} | +""") + + # Comments (investigator responses) + if comments: + st.markdown("### πŸ’¬ Messages de l'enquΓͺteur") + for c in comments: + author = c.get("user", {}).get("login", "EnquΓͺteur") + body = c.get("body", "") + date = c.get("created_at", "")[:10] + + # Filter internal notes (starting with [INTERNE]) + if body.startswith("[INTERNE]"): + continue + + st.markdown(f"**{author}** β€” {date}") + st.info(body) + else: + st.markdown(""" +
+ℹ️ Aucun message de l'enquΓͺteur pour le moment. Vous serez informΓ©(e) de l'avancement. +
+""", unsafe_allow_html=True) + + # Add supplementary info + st.markdown("---") + st.markdown("### πŸ“Ž Ajouter des informations") + + supplement = st.text_area( + "Message complΓ©mentaire", + placeholder="Ajoutez des prΓ©cisions, de nouvelles preuves..." + ) + + if st.button("πŸ“€ Envoyer", use_container_width=True): + if supplement.strip(): + client = GiteaClient(GITEA_URL, GITEA_TOKEN, GITEA_OWNER, GITEA_REPO) + msg = f"**πŸ“Ž Information complΓ©mentaire du lanceur d'alerte**\n\n{supplement}" + if client.add_comment(issue["number"], msg): + st.success("Information ajoutΓ©e !") + st.session_state.track_result = None + st.rerun() + else: + st.error("Erreur lors de l'envoi") + else: + st.markdown(""" +
+⚠️ Aucun dossier trouvé pour ce token. Vérifiez votre saisie. +
+""", unsafe_allow_html=True) + +# ═══════════════════════════════════════════════════════════════════════════════ +# MODE: ADMIN (SecuBox Users authenticated) +# ═══════════════════════════════════════════════════════════════════════════════ +elif st.session_state.mode == "admin": + + if not st.session_state.admin_logged_in: + st.markdown("# πŸ” Administration") + st.markdown("Connexion rΓ©servΓ©e aux enquΓͺteurs dΓ©signΓ©s.") + + with st.form("admin_login"): + username = st.text_input("πŸ‘€ Identifiant SecuBox") + password = st.text_input("πŸ”‘ Mot de passe", type="password") + submitted = st.form_submit_button("Se connecter", use_container_width=True) + + if submitted: + if username and password: + with st.spinner("Authentification..."): + success, user, token = authenticate_admin(username, password) + if success: + st.session_state.admin_logged_in = True + st.session_state.admin_user = user + st.session_state.admin_token = token + st.rerun() + else: + st.error("❌ Identifiants incorrects") + else: + st.warning("Remplissez tous les champs") + + else: + # Logged in admin view + st.markdown(f"# πŸ” Administration") + st.markdown(f"ConnectΓ© : **{st.session_state.admin_user}**") + + if st.button("πŸšͺ DΓ©connexion"): + st.session_state.admin_logged_in = False + st.session_state.admin_user = "" + st.session_state.admin_token = "" + st.rerun() + + st.markdown("---") + + if not GITEA_CONFIGURED: + st.error("Gitea non configurΓ©") + else: + client = GiteaClient(GITEA_URL, GITEA_TOKEN, GITEA_OWNER, GITEA_REPO) + + # Tabs for different views + tab_open, tab_all, tab_stats = st.tabs(["πŸ“¬ En cours", "πŸ“‹ Tous", "πŸ“Š Stats"]) + + with tab_open: + st.markdown("### πŸ“¬ Signalements en cours") + issues = client.list_all_issues(state="open") + + if issues: + for issue in issues: + with st.expander(f"#{issue['number']} β€” {issue['title'][:60]}..."): + st.markdown(issue.get("body", "")[:500] + "...") + + # Status update + labels = [l.get("name", "") for l in issue.get("labels", [])] + current_status = "received" + for lbl in labels: + if "validΓ©" in lbl.lower(): + current_status = "validated" + elif "cours" in lbl.lower(): + current_status = "investigating" + + col1, col2 = st.columns(2) + with col1: + new_status = st.selectbox( + "Statut", + ["received", "validated", "investigating", "resolved", "rejected"], + index=["received", "validated", "investigating", "resolved", "rejected"].index(current_status), + key=f"status_{issue['number']}" + ) + with col2: + if st.button("πŸ’Ύ Mettre Γ  jour", key=f"update_{issue['number']}"): + # Update labels + status_labels = { + "received": "πŸ“₯ reΓ§u", + "validated": "βœ… validΓ©", + "investigating": "πŸ” en cours", + "resolved": "βœ”οΈ rΓ©solu", + "rejected": "❌ rejetΓ©" + } + label_id = client.get_or_create_label( + status_labels[new_status], + {"received": "#fef3c7", "validated": "#dbeafe", + "investigating": "#fce7f3", "resolved": "#d1fae5", + "rejected": "#fee2e2"}.get(new_status, "#e5e7eb") + ) + if label_id: + client.update_issue_labels(issue['number'], [label_id]) + # Audit trail for status change + title = issue.get("title", "") + token_match = title.split("β€”")[-1].strip() if "β€”" in title else "" + if token_match: + add_audit_entry( + action="status_change", + token_hash=hashlib.sha256(token_match.encode()).hexdigest(), + data={ + "issue_number": issue['number'], + "from_status": current_status, + "to_status": new_status, + "actor": st.session_state.admin_user + } + ) + st.success("Statut mis Γ  jour !") + + # Add response + st.markdown("---") + response = st.text_area( + "RΓ©ponse au lanceur d'alerte", + key=f"resp_{issue['number']}", + placeholder="Votre message sera visible par le lanceur d'alerte..." + ) + internal = st.text_area( + "Note interne (non visible)", + key=f"internal_{issue['number']}", + placeholder="Notes pour l'Γ©quipe..." + ) + + if st.button("πŸ“€ Envoyer", key=f"send_{issue['number']}"): + if response.strip(): + msg = f"**RΓ©ponse de l'enquΓͺteur** ({st.session_state.admin_user})\n\n{response}" + client.add_comment(issue['number'], msg) + if internal.strip(): + msg = f"[INTERNE] {st.session_state.admin_user}: {internal}" + client.add_comment(issue['number'], msg) + st.success("EnvoyΓ© !") + st.rerun() + else: + st.info("Aucun signalement en cours") + + with tab_all: + st.markdown("### πŸ“‹ Tous les signalements") + all_issues = client.list_all_issues(state="all") + + for issue in all_issues[:20]: + state_emoji = "🟒" if issue.get("state") == "open" else "⚫" + st.markdown(f"{state_emoji} **#{issue['number']}** β€” {issue['title'][:50]}...") + + with tab_stats: + st.markdown("### πŸ“Š Statistiques") + + all_issues = client.list_all_issues(state="all") + open_count = len([i for i in all_issues if i.get("state") == "open"]) + closed_count = len([i for i in all_issues if i.get("state") == "closed"]) + + c1, c2, c3 = st.columns(3) + with c1: + st.metric("Total", len(all_issues)) + with c2: + st.metric("En cours", open_count) + with c3: + st.metric("RΓ©solus", closed_count) diff --git a/package/secubox/secubox-app-streamlit/files/srv/streamlit/apps/alerte_depot/requirements.txt b/package/secubox/secubox-app-streamlit/files/srv/streamlit/apps/alerte_depot/requirements.txt new file mode 100644 index 00000000..99d14264 --- /dev/null +++ b/package/secubox/secubox-app-streamlit/files/srv/streamlit/apps/alerte_depot/requirements.txt @@ -0,0 +1,5 @@ +streamlit>=1.35.0 +requests>=2.31.0 +qrcode[pil]>=7.4.2 +Pillow>=10.0.0 +python-dotenv>=1.0.0