diff --git a/CHANGELOG.md b/CHANGELOG.md index d59e3d4..f7e2723 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.54.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.55.0**. --- @@ -14,6 +14,40 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.55.0] — 2026-10-08 + +### Ajouté + +- **#188 - Secrets masqués : couverture universelle + clic pour copier** + - **Détection élargie** (`backend/secret_redactor.py`) : les formats de + clés les plus répandus sont masqués d'office — OpenAI/Anthropic/ + OpenRouter (`sk-`), Stripe (`sk_live_`, `whsec_`), GitLab (`glpat-`), + GitHub (`ghp_`, `github_pat_`), Google (`AIza…`, `ya29.`), AWS + (`AKIA`/`ASIA`), Slack (`xoxb-`), SendGrid, Hugging Face, npm, Docker, + Resend, Square, Atlassian, Discord, Telegram, jetons `Bearer …` — en + plus des JWT, clés privées et connection strings déjà couverts ; le + plancher des affectations `api_key=`/`token=`/`secret=` passe de 20 à + 8 caractères. + - **Mots de passe masqués à leur tour** : `password=`, `"passwd": "…"`, + `db_password=`, `passphrase=`, `mot de passe=`… toute longueur et + toute quote, avec le libellé `[MOT DE PASSE MASQUÉ]` ; `PWD=` (dossier + de travail shell) reste épargné. + - **Clic sur un masque = copie de la valeur** : l'aperçu authentifié + sert les masques en `` — la + valeur n'apparaît jamais en clair et un clic la copie dans le + presse-papiers (toast + infobulle FR/EN). Les masques passent par des + placeholders qui survivent à la conversion markdown (blocs de code + compris), où un `` serait affiché en toutes lettres ; les blocs + de code masqués ne sont plus re-colorés (highlight.js perdrait le + badge). + - **Aucune fuite à l'extérieur** : partages publics, exports PDF et + contexte IA / MCP ne reçoivent que le libellé, jamais la valeur. + - Guide in-app FR/EN + `docs/GUIDES/AUTHENTIFICATION_SECURITE.md` + (nouvelle section « Secrets masqués dans les aperçus »), README FR/EN, + tests `TestSecretRedactor` (+33). + +--- + ## [2.54.0] — 2026-10-08 ### Ajouté diff --git a/README.fr.md b/README.fr.md index 5b7d5a4..d709c80 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.54.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.55.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -802,7 +802,7 @@ Configurables via l'interface (Settings) ou l'API `/api/config`. - **Rate limiting** : 10 tentatives de login max par IP sur 15 minutes + lockout par compte (5 tentatives) - **Audit log** : écritures/suppressions/config journalisées dans `data/audit.log` (JSON lines, rotation 10 MB) - **Backup automatique** : chaque modification/suppression sauvegardée dans `.obsigate-backup/` avec timestamp -- **Secret redaction** : masquage automatique des JWT, clés API, tokens dans les aperçus +- **Secret redaction** : masquage automatique des JWT, mots de passe, clés API (OpenAI, GitHub, Google, AWS, Slack, Stripe…), tokens dans les aperçus — cliquez sur un masque pour copier la valeur - **Utilisateur non-root** : conteneur Docker sous `obsigate` (UID 1000) - **Volumes read-only** : vaults montées en `:ro` par défaut - **Secrets dans `.env`** : jamais dans `docker-compose.yml` @@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.54.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.55.0). --- -*Projet : ObsiGate | Version : 2.54.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.55.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 199ab44..1bb829e 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.54.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.55.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -964,7 +964,7 @@ These parameters are configurable via the interface (Settings) or the `/api/conf - **Rate limiting** : 10 login attempts max per IP over 15 minutes + per-account lockout (5 attempts) - **Audit log** : All writes, deletions, and config changes are logged in `data/audit.log` (JSON lines, 10 MB rotation) - **Automatic backup** : Every file modification or deletion is saved in `.obsigate-backup/` with timestamp -- **Secret redaction** : Automatic masking of JWTs, API keys, tokens, and connection strings in previews +- **Secret redaction** : Automatic masking of JWTs, passwords, API keys (OpenAI, GitHub, Google, AWS, Slack, Stripe…), tokens and connection strings in previews — click a mask to copy the value - **Non-root user** : The Docker container runs under user `obsigate` (UID 1000) - **Read-only volumes** : Vaults are mounted as `:ro` by default in docker-compose - **Secrets in `.env`** : Passwords and tokens are never in `docker-compose.yml` @@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.54.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.55.0). --- -*Project: ObsiGate | Version: 2.54.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.55.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 99aed79..c2576f1 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.54.0 +2.55.0 diff --git a/backend/render.py b/backend/render.py index 9031f76..428e4b0 100644 --- a/backend/render.py +++ b/backend/render.py @@ -21,7 +21,7 @@ import mistune from backend.image_processor import preprocess_images from backend.indexer import find_file_in_index, get_vault_data -from backend.secret_redactor import redact_file_content +from backend.secret_redactor import redact_with_placeholders, restore_masks from backend.services.sanitizer import sanitize_html @@ -166,7 +166,13 @@ def _normalize_line_breaks(text: str) -> str: return "".join(parts) -def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | None = None) -> str: +def _render_markdown( + raw_md: str, + vault_name: str, + current_file_path: Path | None = None, + *, + click_to_copy: bool = False, +) -> str: """Render a markdown string to HTML with wikilink and image support. Uses the cached singleton mistune renderer for performance. @@ -175,6 +181,10 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | Non raw_md: Raw markdown text (frontmatter already stripped). vault_name: Current vault for wikilink resolution context. current_file_path: Absolute path to the current markdown file. + click_to_copy: Restore masked secrets as clickable badges carrying + the real value (authenticated app preview, feature #188). + Public shares and PDF exports keep plain labels: the secret + never reaches their HTML. Returns: HTML string. @@ -184,8 +194,13 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | Non vault_root = Path(vault_data["path"]) if vault_data else None attachments_path = vault_data.get("config", {}).get("attachmentsPath") if vault_data else None - # Redact secrets before rendering (P0 security) - raw_md = redact_file_content(raw_md, str(current_file_path) if current_file_path else "") + # Redact secrets before rendering (P0 security). Placeholders survive + # the markdown conversion (fenced code blocks included) and are turned + # back into visible masks — clickable badges when click_to_copy — right + # after the HTML is produced (feature #188). + raw_md, secret_entries = redact_with_placeholders( + raw_md, str(current_file_path) if current_file_path else "" + ) # Preprocess images first if vault_root: @@ -201,6 +216,10 @@ def _render_markdown(raw_md: str, vault_name: str, current_file_path: Path | Non # renderers HTML renvoient toujours `str` à l'exécution). rendered = cast(str, _markdown_renderer(converted)) + # Restore secret masks (plain labels, or clickable badges carrying the + # real value on the authenticated app preview — feature #188). + rendered = restore_masks(rendered, secret_entries, click_to_copy=click_to_copy) + # Add heading IDs for TOC navigation rendered = _add_heading_ids(rendered) diff --git a/backend/routers/files_read.py b/backend/routers/files_read.py index ab2474b..ab4ac6b 100644 --- a/backend/routers/files_read.py +++ b/backend/routers/files_read.py @@ -648,7 +648,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative tags = _extract_tags(post) title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " ")) - html_content = _render_markdown(post.content, vault_name, file_path) + html_content = _render_markdown(post.content, vault_name, file_path, click_to_copy=True) return { "vault": vault_name, diff --git a/backend/secret_redactor.py b/backend/secret_redactor.py index 1ee56f2..1a99313 100644 --- a/backend/secret_redactor.py +++ b/backend/secret_redactor.py @@ -1,48 +1,104 @@ """ Secret redactor: masks sensitive patterns in rendered text. -Scans for common secret patterns and replaces them with [MASQUÉ] -before content is served to the frontend. Prevents accidental -exposure of API keys, tokens, and passwords in previews. +Scans for common secret patterns and replaces them with a French mask +label (``[CLÉ API MASQUÉE]``, ``[MOT DE PASSE MASQUÉ]``, …) before content +is served to the frontend. Prevents accidental exposure of API keys, +tokens, and passwords in previews. Patterns detected: - - Generic API keys (long alphanumeric strings with key/secret/token prefix) + - Generic API keys (``api_key=…``, ``token: …`` — values of 8+ chars) + - Passwords (``password=…``, ``"passwd": "…"`` — any length) - JWT tokens (eyJ... base64url) - - AWS-style keys (AKIA..., sk-..., etc.) + - Provider key formats: OpenAI/Anthropic/OpenRouter (``sk-``), Stripe, + GitLab, Google (``AIza…`` / ``ya29.``), AWS, GitHub, Slack, SendGrid, + Hugging Face, npm, Docker, Resend, Square, Atlassian, Discord, + Telegram, ``Bearer …`` tokens - Private key blocks (-----BEGIN ... PRIVATE KEY-----) - Connection strings with passwords + - Bare hex secrets next to a secret keyword (BUG-035) + +Interactive masking (feature #188): :func:`redact_with_placeholders` +returns the text with every mask replaced by an opaque placeholder plus +the list of ``(label, secret)`` entries; :func:`restore_masks` turns the +placeholders back into plain labels (public shares, PDF exports, AI +context) or into clickable ```` +badges (authenticated app preview) so a click copies the real value to +the clipboard. """ +from __future__ import annotations + +import html as _html import logging import re logger = logging.getLogger("obsigate.redactor") # --- Patterns --- -# Order matters: more specific patterns first -_PATTERNS = [ +# Each entry is ``(pattern, replacement, secret_group)``: +# * ``replacement``: a literal label, a ``\\1``-style template, or a +# callable receiving the match and returning the visible label; +# * ``secret_group``: index of the group holding the value that a click +# copies to the clipboard (feature #188). +_PATTERNS: list[tuple[re.Pattern[str], object, int]] = [ # Private key blocks - (re.compile(r'-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----.*?-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----', re.DOTALL), '[CLÉ PRIVÉE MASQUÉE]'), - + (re.compile(r'-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----.*?-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----', re.DOTALL), '[CLÉ PRIVÉE MASQUÉE]', 0), + # JWT tokens (base64url encoded, starts with eyJ) - (re.compile(r'eyJ[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}'), '[JWT MASQUÉ]'), - + (re.compile(r'eyJ[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}\.[a-zA-Z0-9_-]{20,}'), '[JWT MASQUÉ]', 0), + # Connection strings with passwords - (re.compile(r'(?:mongodb|mysql|postgres(?:ql)?|redis|sqlite)://[^:]+:[^@\s]+@'), '[CONNECTION_STRING MASQUÉE]'), - - # Generic API key patterns: key=... or token=... or secret=... - (re.compile(r'(?:api[_-]?key|apikey|secret|token|password|passwd|auth[_-]?token)\s*[:=]\s*[\'"]?([^\s\'"]{20,})[\'"]?', re.IGNORECASE), - lambda m: f'{m.group(0).split("=")[0].split(":")[0]}=[MASQUÉ]' if "=" in m.group(0) or ":" in m.group(0) else '[MASQUÉ]'), - - # Prefixed API keys (sk-..., pk-..., rk-...) - (re.compile(r'(?:sk|pk|rk)-[a-zA-Z0-9]{20,}'), '[CLÉ API MASQUÉE]'), - - # AWS access keys - (re.compile(r'AKIA[0-9A-Z]{16}'), '[AWS_KEY MASQUÉ]'), - + (re.compile(r'(?:mongodb|mysql|postgres(?:ql)?|redis|sqlite)://[^:]+:[^@\s]+@'), '[CONNECTION_STRING MASQUÉE]', 0), + + # Passwords — any length, bare or quoted (``password=…``, + # ``"passwd": "…"``). The left side may carry a qualifier + # (``db_password``, ``DATABASE.PASSWORD``, ``user_pwd``); a *bare* + # ``PWD=`` (shell working directory) must NOT match, hence ``pwd`` + # only in its qualified branch. + (re.compile( + r'(?i)((?:[A-Za-z0-9_.-]*(?:password|passwd|passphrase|mot\s+de\s+passe)' + r'|[A-Za-z0-9_.-]+pwd)["\']?\s*[:=]\s*["\']?)([^\s"\',;]{4,})'), + r'\1[MOT DE PASSE MASQUÉ]', 2), + + # Generic API key assignments: api_key=…, token=…, secret=… — values + # of 8+ characters (short enough to catch real keys, long enough to + # skip plain words). + (re.compile(r'(?i)([A-Za-z0-9_.-]*(?:api[_-]?key|apikey|secret|token|auth[_-]?token)["\']?\s*[:=]\s*["\']?)([^\s\'"]{8,})'), + lambda m: f'{m.group(1)}[MASQUÉ]' if ("=" in m.group(0) or ":" in m.group(0)) else '[MASQUÉ]', 2), + # GitHub tokens (ghp_, gho_, ghu_, ghs_, ghr_) - (re.compile(r'gh[pousr]_[a-zA-Z0-9]{36,}'), '[GITHUB_TOKEN MASQUÉ]'), - + (re.compile(r'gh[pousr]_[a-zA-Z0-9]{36,}'), '[GITHUB_TOKEN MASQUÉ]', 0), + + # AWS access keys + (re.compile(r'(?:AKIA|ASIA)[0-9A-Z]{16}'), '[AWS_KEY MASQUÉ]', 0), + + # Provider key formats (feature #188) — one alternation covering the + # large majority of token shapes in the wild. + (re.compile( + r'(? tuple: + +def _redact_bare_hex_secrets(text: str, mask) -> tuple[str, int]: """Redact 40–64 char hex strings only when a secret keyword is nearby. Git/SHA/checksum contexts are left untouched (BUG-035). Args: text: Text to scan. + mask: ``mask(original, label) -> str`` replacement builder. Returns: (redacted_text, redaction_count) tuple. @@ -82,12 +147,38 @@ def _redact_bare_hex_secrets(text: str) -> tuple: return match.group(0) if _SECRET_CONTEXT_RE.search(window): count += 1 - return '[HEX_KEY MASQUÉ]' + return mask(match.group(0), '[HEX_KEY MASQUÉ]') return match.group(0) return _HEX_RE.sub(_replace, text), count +def _redact(text: str, mask) -> tuple[str, int]: + """Apply every pattern; ``mask(original, label) -> str`` builds the + replacement (plain label, or placeholder for the interactive mode).""" + count = 0 + result = text + for pattern, replacement, secret_group in _PATTERNS: + + def _sub(match: re.Match, replacement=replacement, secret_group=secret_group) -> str: + if callable(replacement): + label = replacement(match) + else: + label = match.expand(str(replacement)) + return mask(match.group(secret_group), label) + + new_result, n = pattern.subn(_sub, result) + count += n + result = new_result + result, hex_count = _redact_bare_hex_secrets(result, mask) + return result, count + hex_count + + +def _plain_mask(original: str, label: str) -> str: + """Plain masking: only the visible label survives.""" + return label + + def redact(text: str) -> tuple: """Redact sensitive patterns from text. @@ -97,22 +188,84 @@ def redact(text: str) -> tuple: Returns: (redacted_text, redaction_count) tuple. """ - count = 0 - result = text - for pattern, replacement in _PATTERNS: - if callable(replacement): - new_result, n = pattern.subn(replacement, result) - else: - new_result, n = pattern.subn(str(replacement), result) - count += n - result = new_result - result, hex_count = _redact_bare_hex_secrets(result) - count += hex_count + result, count = _redact(text, _plain_mask) if count > 0: logger.info(f"Redacted {count} secret(s) from content") return result, count +def redact_with_placeholders(text: str, file_path: str = "") -> tuple[str, list[tuple[str, str]]]: + """Redact *text*, replacing every mask with an opaque placeholder. + + Used by the markdown rendering pipeline: placeholders survive the + markdown → HTML conversion (fenced code blocks included, where a + literal ```` would be shown as text), then + :func:`restore_masks` turns them back into labels or clickable badges. + + Args: + text: The raw text content to scan. + file_path: Optional file path for logging context. + + Returns: + (text_with_placeholders, entries) where *entries* is the list of + ``(label, secret)`` tuples referenced by the placeholders, in + order of appearance. + """ + entries: list[tuple[str, str]] = [] + + def mask(original: str, label: str) -> str: + entries.append((label, original)) + return f"{_PLACEHOLDER_OPEN}{len(entries) - 1}{_PLACEHOLDER_CLOSE}" + + result, count = _redact(text, mask) + if count > 0: + logger.warning(f"Redacted {count} potential secret(s) from {file_path or ''}") + return result, entries + + +def restore_masks( + text: str, + entries: list[tuple[str, str]], + *, + click_to_copy: bool = False, +) -> str: + """Turn placeholders produced by :func:`redact_with_placeholders` back + into visible masks. + + Args: + text: Rendered HTML still containing placeholders. + entries: The ``(label, secret)`` list returned alongside. + click_to_copy: When True (authenticated app preview), each mask + becomes ``label`` + so a click copies the real value. When False (public shares, + PDF exports), only the plain label is restored — the secret + never reaches the page. + + Returns: + The text with every placeholder replaced. + """ + if not entries: + return text + + def _sub(match: re.Match) -> str: + idx = int(match.group(1)) + if idx >= len(entries): + return "" + label, original = entries[idx] + label_esc = _html.escape(str(label), quote=False) + if not click_to_copy: + return label_esc + return ( + '' + + label_esc + + "" + ) + + return _PLACEHOLDER_RE.sub(_sub, text) + + def redact_file_content(content: str, file_path: str = "") -> str: """Redact a file's content for preview rendering. diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 106e37c..e3410c6 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.54.0" +version = "2.55.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 8d8e875..85c446a 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.54.0" +version = "2.55.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 519265b..2009101 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.54.0", + "version": "2.55.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/GUIDES/AUTHENTIFICATION_SECURITE.md b/docs/GUIDES/AUTHENTIFICATION_SECURITE.md index fd3325f..36f4d19 100644 --- a/docs/GUIDES/AUTHENTIFICATION_SECURITE.md +++ b/docs/GUIDES/AUTHENTIFICATION_SECURITE.md @@ -174,7 +174,7 @@ et [`features/api-mcp-tokens-107.md`](../features/api-mcp-tokens-107.md). | **Rate limiting MFA** | Appliqué aux endpoints TOTP/WebAuthn/recovery | | **Audit log** | Écritures, suppressions, config dans `data/audit.log` (JSON lines, rotation 10 Mo) | | **Backup automatique** | Avant chaque modification/suppression dans `.obsigate-backup/` | -| **Redaction** | Masquage des JWT, clés API, tokens dans les aperçus et retours d'outils | +| **Redaction** | Masquage des JWT, mots de passe, clés API (OpenAI, GitHub, Google, AWS, Slack, Stripe…), tokens et connection strings dans les aperçus markdown et les retours d'outils — clic sur un masque = copie de la valeur | | **CSP** | `object-src`, `base-uri`, `form-action`, `frame-ancestors` restreints | | **Cookie HttpOnly** | Jeton retiré de `sessionStorage`, porté par cookie HTTP-only | | **Utilisateur non-root** | Conteneur sous `obsigate` (UID 1000) | @@ -187,6 +187,30 @@ et [`features/api-mcp-tokens-107.md`](../features/api-mcp-tokens-107.md). Une politique minimale est validée à la création d'un compte. Choisissez des mots de passe longs et uniques ; activez le MFA pour les comptes admin. +### Secrets masqués dans les aperçus + +Quand une note contient un secret, l'aperçu markdown le remplace par un masque +— `[CLÉ API MASQUÉE]`, `[MOT DE PASSE MASQUÉ]`, `[JWT MASQUÉ]`, +`[CONNECTION_STRING MASQUÉE]` — au lieu de la valeur : + +- **Détectés automatiquement** : mots de passe (`password=`, `"passwd": "…"`, + `db_password=…`, toute longueur), affectations `api_key=` / `token=` / + `secret=`, JWT, clés privées, connection strings, hex en contexte secret, + et les formats de clés les plus répandus — OpenAI/Anthropic/OpenRouter + (`sk-`), GitHub (`ghp_`, `github_pat_`), Google (`AIza…`, `ya29.`), AWS + (`AKIA…`/`ASIA…`), Slack (`xoxb-`), Stripe (`sk_live_`, `whsec_`), GitLab + (`glpat-`), Hugging Face (`hf_`), npm, Docker, SendGrid, Resend, Square, + Atlassian, Discord, Telegram, jetons `Bearer …`. +- **Clic = copie** : dans l'application (aperçu authentifié), cliquer sur un + masque copie la valeur réelle dans le presse-papiers (infobulle « Cliquer + pour copier la valeur »). La valeur n'apparaît jamais en clair à l'écran. +- **Jamais exposé à l'extérieur** : partages publics (`/s/{token}`), exports + PDF et contexte envoyé à l'IA / au serveur MCP ne reçoivent que le libellé, + jamais la valeur derrière le masque. +- **Hors périmètre** : la vue « source » (fichier brut) et les aperçus de + fichiers non-markdown ne sont pas masqués — c'est le fichier lui-même qui + est affiché. + --- ## 7. Variables d'environnement diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 88b834c..f66b349 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.54.0 | **Dernière mise à jour :** 2026-10-08 +> **Version :** 2.55.0 | **Dernière mise à jour :** 2026-10-08 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -494,6 +494,40 @@ --- +## ✅ Terminé — Secrets masqués + +### 188. Secrets masqués — couverture universelle des clés API & mots de passe + clic pour copier + +- **Effort :** 1 jour | **Impact :** 🟡 +- **Statut :** ✅ **livré le 2026-10-08** — ouvert le 2026-10-08 +- **Fiche :** [features/secret-mask-188.md](./features/secret-mask-188.md) +- **Description :** le redacteur (`secret_redactor.py`) ne couvrait que les + JWT, clés `sk-`/`AKIA`/`ghp_`, connection strings et affectations + `key=value` de 20+ caractères — la plupart des clés API réelles et **tous + les mots de passe courts** passaient en clair dans l'aperçu markdown, et + un masque affiché n'était pas récupérable sans ouvrir la source. #188 + couvre la majorité des formats de clés, masque les mots de passe quelle + que soit leur longueur et rend chaque masque **cliquable** dans l'aperçu + authentifié (copie dans le presse-papiers) sans jamais exposer la valeur + à l'extérieur (partages, PDF, IA/MCP). +- **Sous-tâches :** + - [x] **A1** Détection universelle : table de formats fournisseur (OpenAI, + Stripe, GitLab, GitHub, Google, AWS, Slack, SendGrid, Hugging Face, + npm, Docker, Resend, Square, Atlassian, Discord, Telegram, + `Bearer …`) + plancher des affectations génériques 20 → 8 caractères + - [x] **A2** Mots de passe : `[MOT DE PASSE MASQUÉ]` toute longueur / + quote (`password=`, `"passwd":`, `db_password=`, `mot de passe=`) ; + `PWD=` shell épargné + - [x] **A3** Clic → copie : placeholders markdown-safe (blocs de code + compris) réécrits en `` + côté aperçu authentifié (listener délégué, toast + infobulle + i18n FR/EN) ; libellé seul pour partages / PDF / IA / MCP ; + code masqué non re-coloré (highlight.js perdrait le badge) + - [x] **A4** Guide in-app FR/EN, `GUIDES/AUTHENTIFICATION_SECURITE.md`, + README FR/EN, tests `TestSecretRedactor` (+33) + +--- + ## ⚪ Backlog — Priorité 4 (P4) ### 73. Synchronisation multi-appareils — Obsidian Sync compatible @@ -686,6 +720,7 @@ | 184 | Tableur — peinture de format complète, multi-lignes/colonnes, grille A → Z × 1000 (croissance par blocs) | 2.54.0 | [features/xlsx-sheet-input-181.md](./features/xlsx-sheet-input-181.md) | | 185 | Tableur — barre de menus Google Sheets (10 menus), ruban Sheets, grille unie | 2.54.0 | [features/xlsx-menus-185.md](./features/xlsx-menus-185.md) | | 186 | Création d'un fichier Excel (`.xlsx`) depuis la modale « Créer un fichier » | 2.54.0 | [features/create-file-xlsx-186.md](./features/create-file-xlsx-186.md) | +| 188 | Secrets — détection universelle des clés API & mots de passe, masque cliquable (clic = copie) | Unreleased | [features/secret-mask-188.md](./features/secret-mask-188.md) | | 159 | Desktop — gestion des vaults & dossiers : retrait par menu contextuel + section Configuration (ajout vault/dossier racine) | 2.50.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) | | 160 | Desktop — premier lancement professionnel (répertoire `%USERPROFILE%\ObsiGate` + `Prise en main.md`) et section Configuration harmonisée | 2.51.0 | [features/desktop-tauri.md](./features/desktop-tauri.md) | | BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.md) | diff --git a/docs/features/secret-mask-188.md b/docs/features/secret-mask-188.md new file mode 100644 index 0000000..ae5d6ad --- /dev/null +++ b/docs/features/secret-mask-188.md @@ -0,0 +1,57 @@ +# Fiche — #188 · Secrets masqués : couverture universelle + clic pour copier + +- **Statut** : ✅ livré le 2026-10-08 (ouvert le 2026-10-08) +- **Effort** : ~1 jour · **Impact** : 🟡 +- **Zones** : `backend/secret_redactor.py`, `backend/render.py`, + `backend/routers/files_read.py`, `frontend/js/viewer.js`, + `frontend/style.css`, `frontend/locales/{fr,en}.json` + +## Problème + +Le redacteur (`secret_redactor.py`) ne masquait que les JWT, les clés +`sk-`/`pk-`/`rk-`, `AKIA…`, `ghp_…`, les connection strings et les +affectations `api_key=`/`token=`/`secret=` de **20 caractères ou plus**. +Résultat : la majorité des clés API réelles (Google, Slack, Stripe, GitLab, +Hugging Face, npm…) et **tous les mots de passe courts** passaient en clair +dans l'aperçu markdown, et un masque affiché (`[CLÉ API MASQUÉE]`) n'était +pas récupérable : il fallait ouvrir la source pour relire la valeur. + +## Solution + +1. **Détection universelle** — une alternation unique de formats + fournisseur (OpenAI/Anthropic `sk-`, Stripe `sk_live_`/`whsec_`, GitLab + `glpat-`, GitHub `ghp_`/`github_pat_`, Google `AIza…`/`ya29.`, AWS + `AKIA`/`ASIA`, Slack `xoxb-`, SendGrid `SG.`, Hugging Face `hf_`, npm, + Docker, Resend, Square, Atlassian, Discord, Telegram, `Bearer …`) en + plus des motifs déjà présents ; le plancher des affectations génériques + passe de 20 à 8 caractères. +2. **Mots de passe** — motif dédié (`[MOT DE PASSE MASQUÉ]`) valable toute + longueur et toute quote (`password=`, `"passwd": "…"`, `db_password=`, + `passphrase=`, `mot de passe=`), qualificateur inclus. `PWD=` (dossier de + travail shell) reste épargné : `pwd` n'est reconnu que qualifié. +3. **Clic → copie** — le pipeline de rendu passe par des + **placeholders** (`\uE000n\uE001`) qui survivent à la conversion markdown + (un `` littéral dans un bloc de code serait affiché tel quel), puis + `restore_masks()` les réécrit après le rendu : + - **aperçu authentifié** (`GET /api/file/…`, `click_to_copy=True`) : + `[CLÉ API MASQUÉE]` — + la valeur n'est jamais affichée, un clic la copie (listener délégué + unique sur la zone de contenu, toast i18n FR/EN, infobulle) ; + - **partages publics, exports PDF, contexte IA / MCP** : libellé seul, + la valeur ne quitte pas le serveur. + Les blocs `
` portant un masque ne sont pas re-colorés
+   (highlight.js reconstruirait le markup et perdrait le badge).
+
+## Hors périmètre (volontaire)
+
+- La vue « source » (fichier brut) et les aperçus de fichiers
+  **non-markdown** ne sont pas masqués : c'est le fichier lui-même qui est
+  affiché, le redacteur ne s'y appliquait jamais.
+- Le serveur MCP / l'assistant IA continuent de recevoir les libellés seuls
+  (inchangé).
+
+## Tests
+
+`tests/test_api_main.py::TestSecretRedactor` : formats fournisseurs
+(paramétrés), mots de passe (paramétrés), `PWD=` non masqué, round-trip
+placeholder → span cliquable, rendu markdown cliquable vs libellé seul.
diff --git a/frontend/index.html b/frontend/index.html
index 749838f..e25a168 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -5302,8 +5302,12 @@ curl -X POST https://votre-serveur.com/webhook \
                             
  • Secret redactor : Masque - JWT, clés API, tokens GitHub dans les - aperçus
  • + JWT, mots de passe et clés API (OpenAI, + Anthropic, GitHub, Google, AWS, Slack, + Stripe, GitLab, Hugging Face…) dans les + aperçus markdown ; cliquez sur un masque + pour copier la valeur dans le + presse-papiers
  • Path traversal : Validation des chemins contre les attaques diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index b41bdfe..ca8001e 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -7276,7 +7276,10 @@ export function renderFile(data) { prettyBtn.classList.add("active"); } else { mdDiv.innerHTML = data.html; - mdDiv.querySelectorAll("pre code").forEach((block) => safeHighlight(block)); + mdDiv.querySelectorAll("pre code").forEach((block) => { + if (block.querySelector(".secret-mask")) return; + safeHighlight(block); + }); prettyBtn.classList.remove("active"); } }); @@ -7327,8 +7330,10 @@ export function renderFile(data) { renderBacklinksPanel(data.vault, data.path, area); } - // Highlight code blocks + // Highlight code blocks — a block carrying a secret mask (#188) must be + // left alone: highlight.js rebuilds the markup and would drop the badge. area.querySelectorAll("pre code").forEach((block) => { + if (block.querySelector(".secret-mask")) return; safeHighlight(block); }); @@ -7370,6 +7375,23 @@ export function renderFile(data) { }); }); + // Secret masks (#188): click copies the real value to the clipboard. + // The handler is delegated (and attached once) so it survives re-renders. + area.querySelectorAll(".secret-mask").forEach((mask) => { + mask.title = t("viewer.secret_copy_hint"); + }); + if (!area.dataset.secretMaskWired) { + area.dataset.secretMaskWired = "1"; + area.addEventListener("click", (e) => { + const mask = e.target instanceof Element ? e.target.closest(".secret-mask") : null; + if (!mask || !area.contains(mask)) return; + const secret = mask.getAttribute("data-secret"); + if (!secret) return; + const ok = copyToClipboard(secret); + showToast(ok ? t("viewer.secret_copied") : t("viewer.secret_copy_failed"), ok ? "success" : "error"); + }); + } + safeCreateIcons(); area.scrollTop = 0; diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 361b120..3ffbe11 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1004,7 +1004,7 @@ "help.desc_8f0bffe3": "JWT tokens", "help.desc_9186a3a3": "Explorez l'arborescence : Naviguez dans les dossiers", "help.desc_93902d18": "Utilisez les tags : Filtrez par tags pour affiner", - "help.desc_9846fc07": "In the", + "help.desc_9846fc07": ": Masks JWTs, passwords and API keys (OpenAI, Anthropic, GitHub, Google, AWS, Slack, Stripe, GitLab, Hugging Face…) in markdown previews — click a mask to copy the value to the clipboard", "help.desc_985d759b": "Token unique : 64 caractères hexadécimaux, impossible à deviner", "help.desc_991307d6": " — Generate content from the selection:", "help.desc_995a711f": ": Opens the CodeMirror editor", @@ -2221,6 +2221,9 @@ "viewer.export_html": "Export as HTML", "viewer.export_md_bundle": "Export as Markdown bundle (.zip)", "viewer.export_start": "Exporting...", + "viewer.secret_copy_hint": "Click to copy the value", + "viewer.secret_copied": "Value copied to the clipboard", + "viewer.secret_copy_failed": "Copy failed", "viewer.export_title": "Export document", "viewer.forge_brand": "Forge", "viewer.forge_title": "Forge (new editor)", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 741ffe9..c94aa3a 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1004,7 +1004,7 @@ "help.desc_8f0bffe3": ": Tokens JWT\n avec mots de passe hachés Argon2id", "help.desc_9186a3a3": "Explorez l'arborescence :\n Naviguez dans les dossiers", "help.desc_93902d18": "Utilisez les tags : Filtrez\n par tags pour affiner", - "help.desc_9846fc07": ": Masque\n JWT, clés API, tokens GitHub dans les\n aperçus", + "help.desc_9846fc07": ": Masque JWT, mots de passe et clés API (OpenAI, Anthropic, GitHub, Google, AWS, Slack, Stripe, GitLab, Hugging Face…) dans les aperçus markdown ; cliquez sur un masque pour copier la valeur dans le presse-papiers", "help.desc_985d759b": "Token unique : 64\n caractères hexadécimaux, impossible à\n deviner", "help.desc_991307d6": "— Génère du\n contenu à partir de la sélection :", "help.desc_995a711f": ": Ouvre l'éditeur\n CodeMirror", @@ -2221,6 +2221,9 @@ "viewer.export_html": "Exporter en HTML", "viewer.export_md_bundle": "Exporter en bundle Markdown (.zip)", "viewer.export_start": "Export en cours...", + "viewer.secret_copy_hint": "Cliquer pour copier la valeur", + "viewer.secret_copied": "Valeur copiée dans le presse-papiers", + "viewer.secret_copy_failed": "Échec de la copie", "viewer.export_title": "Exporter le document", "viewer.forge_brand": "Forge", "viewer.forge_title": "Forge (nouvel éditeur)", diff --git a/frontend/style.css b/frontend/style.css index 93c5ee4..2fe7c8c 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -2324,6 +2324,18 @@ select { cursor: default; } +/* Secret masks (#188) — click copies the real value to the clipboard */ +.secret-mask { + cursor: pointer; + padding: 0 3px; + border-radius: 3px; + border-bottom: 1px dashed var(--text-muted); + background: var(--bg-secondary); +} +.secret-mask:hover { + background: var(--bg-hover); +} + /* Image placeholders */ .image-not-found { display: inline-block; diff --git a/package.json b/package.json index 7655a32..0634c5a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.54.0", + "version": "2.55.0", "description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.", "main": "patch.js", "directories": { diff --git a/tests/test_api_main.py b/tests/test_api_main.py index 6f111d9..dcfdf54 100644 --- a/tests/test_api_main.py +++ b/tests/test_api_main.py @@ -845,6 +845,95 @@ class TestSecretRedactor: text = f"value {blob} end" assert redact_file_content(text) == text + # ── Feature #188 : couverture universelle + clic → copie ────────── + + @pytest.mark.parametrize("secret", [ + "sk-" + "a" * 30, # OpenAI / OpenRouter + "sk_live_" + "b" * 24, # Stripe + "whsec_" + "c" * 24, # Stripe / Svix + "glpat-" + "d" * 24, # GitLab + "github_pat_" + "e" * 30, # GitHub fine-grained + "npm_" + "f" * 36, # npm + "dckr_pat_" + "g" * 24, # Docker Hub + "hf_" + "h" * 32, # Hugging Face + "AIza" + "I" * 35, # Google API key + "ya29." + "J" * 25, # Google OAuth + "xoxb-" + "K" * 24, # Slack + "SG." + "L" * 24, # SendGrid + "re_" + "M" * 40, # Resend + "sq0atp-" + "N" * 24, # Square + "ATATT" + "O" * 24, # Atlassian + "AKIA" + "P" * 16, # AWS + "ASIA" + "Q" * 16, # AWS STS + "ghp_" + "R" * 36, # GitHub legacy + "123456789:AA" + "S" * 33, # Telegram bot (35 chars after ":") + "Bearer " + "T" * 32, # Authorization header + ]) + def test_universal_provider_keys_masked(self, secret): + """#188 — the large majority of token formats end up masked.""" + from backend.secret_redactor import redact_file_content + result = redact_file_content(f"here: {secret}") + assert secret not in result + assert "MASQUÉ" in result + + @pytest.mark.parametrize("text", [ + "password=hunter2", + "PASSWORD=short", + "db_password=abc12345", + 'password: "quoted1"', + '{"passwd": "jsonpass"}', + "passphrase=longenough", + "mot de passe=chezmoi", + "user_pwd=s3cret", + "token=abcdef12", # 8+ char generic values are masked too + ]) + def test_passwords_masked_any_length(self, text): + """#188 — passwords are masked whatever their length / quoting.""" + from backend.secret_redactor import redact_file_content + result = redact_file_content(text) + assert "MASQUÉ" in result + # No raw value left behind + assert text.split("=", 1)[-1].split(":", 1)[-1].strip('"\' ') not in result + + def test_pwd_env_var_not_masked(self): + """``PWD=`` is a shell working directory, not a password.""" + from backend.secret_redactor import redact_file_content + text = "PWD=/home/bruno/notes" + assert redact_file_content(text) == text + + def test_placeholders_restore_clickable_mask(self): + """#188 — placeholders become spans carrying the real value.""" + from backend.secret_redactor import redact_with_placeholders, restore_masks + secret = "sk-" + "a" * 30 + text, entries = redact_with_placeholders(f"key: {secret}") + assert secret not in text and entries + html = restore_masks(text, entries, click_to_copy=True) + assert f'data-secret="{secret}"' in html + assert 'class="secret-mask"' in html + # Plain restore never leaks the value + plain = restore_masks(text, entries) + assert secret not in plain and "data-secret" not in plain + + def test_render_markdown_masks_clickable_in_app(self): + """#188 — the authenticated preview serves clickable masks.""" + from backend.render import _render_markdown + secret = "sk-" + "a" * 30 + md = f"```bash\nexport KEY={secret}\n```\n" + html = _render_markdown(md, "test_vault", None, click_to_copy=True) + # The value only ever appears inside the data-secret attribute + assert 'class="secret-mask"' in html + assert f'data-secret="{secret}"' in html + assert f">{secret}" not in html # never as visible text + + def test_render_markdown_plain_without_click(self): + """Public shares / PDF exports get the plain label only.""" + from backend.render import _render_markdown + secret = "sk-" + "a" * 30 + html = _render_markdown(f"Cle: {secret}\n", "test_vault", None) + assert secret not in html + assert "[CLÉ API MASQUÉE]" in html + assert "data-secret" not in html + # ═══════════════════════════════════════════════════════════════════ # Static / PWA caching (Cloudflare / mobile freshness)