feat: secrets masqués — couverture universelle clés API/mots de passe + clic pour copier (#188)
This commit is contained in:
+35
-1
@@ -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).
|
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
|
> **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 `<span class="secret-mask" data-secret="…">` — 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 `<span>` 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
|
## [2.54.0] — 2026-10-08
|
||||||
|
|
||||||
### Ajouté
|
### Ajouté
|
||||||
|
|||||||
+4
-4
@@ -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.
|
**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.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](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)
|
- **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)
|
- **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
|
- **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)
|
- **Utilisateur non-root** : conteneur Docker sous `obsigate` (UID 1000)
|
||||||
- **Volumes read-only** : vaults montées en `:ro` par défaut
|
- **Volumes read-only** : vaults montées en `:ro` par défaut
|
||||||
- **Secrets dans `.env`** : jamais dans `docker-compose.yml`
|
- **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
|
## 📝 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*
|
||||||
|
|||||||
@@ -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.
|
**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.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](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)
|
- **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)
|
- **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
|
- **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)
|
- **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
|
- **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`
|
- **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
|
## 📝 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*
|
||||||
|
|||||||
+23
-4
@@ -21,7 +21,7 @@ import mistune
|
|||||||
|
|
||||||
from backend.image_processor import preprocess_images
|
from backend.image_processor import preprocess_images
|
||||||
from backend.indexer import find_file_in_index, get_vault_data
|
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
|
from backend.services.sanitizer import sanitize_html
|
||||||
|
|
||||||
|
|
||||||
@@ -166,7 +166,13 @@ def _normalize_line_breaks(text: str) -> str:
|
|||||||
return "".join(parts)
|
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.
|
"""Render a markdown string to HTML with wikilink and image support.
|
||||||
|
|
||||||
Uses the cached singleton mistune renderer for performance.
|
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).
|
raw_md: Raw markdown text (frontmatter already stripped).
|
||||||
vault_name: Current vault for wikilink resolution context.
|
vault_name: Current vault for wikilink resolution context.
|
||||||
current_file_path: Absolute path to the current markdown file.
|
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:
|
Returns:
|
||||||
HTML string.
|
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
|
vault_root = Path(vault_data["path"]) if vault_data else None
|
||||||
attachments_path = vault_data.get("config", {}).get("attachmentsPath") if vault_data else None
|
attachments_path = vault_data.get("config", {}).get("attachmentsPath") if vault_data else None
|
||||||
|
|
||||||
# Redact secrets before rendering (P0 security)
|
# Redact secrets before rendering (P0 security). Placeholders survive
|
||||||
raw_md = redact_file_content(raw_md, str(current_file_path) if current_file_path else "")
|
# 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
|
# Preprocess images first
|
||||||
if vault_root:
|
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).
|
# renderers HTML renvoient toujours `str` à l'exécution).
|
||||||
rendered = cast(str, _markdown_renderer(converted))
|
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
|
# Add heading IDs for TOC navigation
|
||||||
rendered = _add_heading_ids(rendered)
|
rendered = _add_heading_ids(rendered)
|
||||||
|
|
||||||
|
|||||||
@@ -648,7 +648,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
|||||||
tags = _extract_tags(post)
|
tags = _extract_tags(post)
|
||||||
|
|
||||||
title = post.metadata.get("title", file_path.stem.replace("-", " ").replace("_", " "))
|
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 {
|
return {
|
||||||
"vault": vault_name,
|
"vault": vault_name,
|
||||||
|
|||||||
+185
-32
@@ -1,47 +1,103 @@
|
|||||||
"""
|
"""
|
||||||
Secret redactor: masks sensitive patterns in rendered text.
|
Secret redactor: masks sensitive patterns in rendered text.
|
||||||
|
|
||||||
Scans for common secret patterns and replaces them with [MASQUÉ]
|
Scans for common secret patterns and replaces them with a French mask
|
||||||
before content is served to the frontend. Prevents accidental
|
label (``[CLÉ API MASQUÉE]``, ``[MOT DE PASSE MASQUÉ]``, …) before content
|
||||||
exposure of API keys, tokens, and passwords in previews.
|
is served to the frontend. Prevents accidental exposure of API keys,
|
||||||
|
tokens, and passwords in previews.
|
||||||
|
|
||||||
Patterns detected:
|
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)
|
- 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-----)
|
- Private key blocks (-----BEGIN ... PRIVATE KEY-----)
|
||||||
- Connection strings with passwords
|
- 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 ``<span class="secret-mask" data-secret="…">``
|
||||||
|
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 logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
logger = logging.getLogger("obsigate.redactor")
|
logger = logging.getLogger("obsigate.redactor")
|
||||||
|
|
||||||
# --- Patterns ---
|
# --- Patterns ---
|
||||||
# Order matters: more specific patterns first
|
# Each entry is ``(pattern, replacement, secret_group)``:
|
||||||
_PATTERNS = [
|
# * ``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
|
# 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)
|
# 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
|
# Connection strings with passwords
|
||||||
(re.compile(r'(?:mongodb|mysql|postgres(?:ql)?|redis|sqlite)://[^:]+:[^@\s]+@'), '[CONNECTION_STRING MASQUÉE]'),
|
(re.compile(r'(?:mongodb|mysql|postgres(?:ql)?|redis|sqlite)://[^:]+:[^@\s]+@'), '[CONNECTION_STRING MASQUÉE]', 0),
|
||||||
|
|
||||||
# Generic API key patterns: key=... or token=... or secret=...
|
# Passwords — any length, bare or quoted (``password=…``,
|
||||||
(re.compile(r'(?:api[_-]?key|apikey|secret|token|password|passwd|auth[_-]?token)\s*[:=]\s*[\'"]?([^\s\'"]{20,})[\'"]?', re.IGNORECASE),
|
# ``"passwd": "…"``). The left side may carry a qualifier
|
||||||
lambda m: f'{m.group(0).split("=")[0].split(":")[0]}=[MASQUÉ]' if "=" in m.group(0) or ":" in m.group(0) else '[MASQUÉ]'),
|
# (``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),
|
||||||
|
|
||||||
# Prefixed API keys (sk-..., pk-..., rk-...)
|
# Generic API key assignments: api_key=…, token=…, secret=… — values
|
||||||
(re.compile(r'(?:sk|pk|rk)-[a-zA-Z0-9]{20,}'), '[CLÉ API MASQUÉE]'),
|
# of 8+ characters (short enough to catch real keys, long enough to
|
||||||
|
# skip plain words).
|
||||||
# AWS access keys
|
(re.compile(r'(?i)([A-Za-z0-9_.-]*(?:api[_-]?key|apikey|secret|token|auth[_-]?token)["\']?\s*[:=]\s*["\']?)([^\s\'"]{8,})'),
|
||||||
(re.compile(r'AKIA[0-9A-Z]{16}'), '[AWS_KEY MASQUÉ]'),
|
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_)
|
# 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'(?<![A-Za-z0-9])(?:'
|
||||||
|
r'sk-[A-Za-z0-9_\-]{16,}' # OpenAI / Anthropic / OpenRouter
|
||||||
|
r'|sk_(?:live|test)_[A-Za-z0-9]{10,}' # Stripe secret key
|
||||||
|
r'|pk_(?:live|test)_[A-Za-z0-9]{10,}' # Stripe publishable key
|
||||||
|
r'|whsec_[A-Za-z0-9]{16,}' # Stripe / Svix webhook secret
|
||||||
|
r'|glpat-[A-Za-z0-9_\-]{20,}' # GitLab personal access token
|
||||||
|
r'|github_pat_[A-Za-z0-9_]{22,}' # GitHub fine-grained PAT
|
||||||
|
r'|npm_[A-Za-z0-9]{36}' # npm automation token
|
||||||
|
r'|dckr_pat_[A-Za-z0-9_\-]{20,}' # Docker Hub token
|
||||||
|
r'|hf_[A-Za-z0-9]{30,}' # Hugging Face token
|
||||||
|
r'|AIza[0-9A-Za-z_\-]{35}' # Google API key
|
||||||
|
r'|ya29\.[0-9A-Za-z_\-]{20,}' # Google OAuth access token
|
||||||
|
r'|xox[baprs]-[0-9A-Za-z\-]{10,}' # Slack token
|
||||||
|
r'|SG\.[A-Za-z0-9_\-]{16,}' # SendGrid API key
|
||||||
|
r'|re_[A-Za-z0-9]{40}' # Resend API key
|
||||||
|
r'|sq0[a-z]{3}-[A-Za-z0-9_\-]{16,}' # Square access token
|
||||||
|
r'|ATATT[A-Za-z0-9_\-]{20,}' # Atlassian access token
|
||||||
|
r'|[NOP][A-Za-z0-9_\-]{23,28}\.[A-Za-z0-9_\-]{6}\.[A-Za-z0-9_\-]{27,}' # Discord bot token
|
||||||
|
r'|\d{8,10}:[A-Za-z0-9_\-]{35}' # Telegram bot token
|
||||||
|
r'|Bearer\s+[A-Za-z0-9._~+/=\-]{20,}' # Authorization: Bearer …
|
||||||
|
r')'),
|
||||||
|
'[CLÉ API MASQUÉE]', 0),
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -61,14 +117,23 @@ _HASH_CONTEXT_RE = re.compile(
|
|||||||
#: How far before the hex string a keyword may appear to count as context.
|
#: How far before the hex string a keyword may appear to count as context.
|
||||||
_HEX_CONTEXT_WINDOW = 60
|
_HEX_CONTEXT_WINDOW = 60
|
||||||
|
|
||||||
|
# --- Interactive masking (feature #188) ---
|
||||||
|
# Private-use-area sentinels: they survive markdown rendering (mistune
|
||||||
|
# treats them as plain text, fenced code blocks included) and are
|
||||||
|
# stripped by ``backend.render._heading_slugify``.
|
||||||
|
_PLACEHOLDER_OPEN = "\uE000"
|
||||||
|
_PLACEHOLDER_CLOSE = "\uE001"
|
||||||
|
_PLACEHOLDER_RE = re.compile("\uE000(\\d+)\uE001")
|
||||||
|
|
||||||
def _redact_bare_hex_secrets(text: str) -> 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.
|
"""Redact 40–64 char hex strings only when a secret keyword is nearby.
|
||||||
|
|
||||||
Git/SHA/checksum contexts are left untouched (BUG-035).
|
Git/SHA/checksum contexts are left untouched (BUG-035).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: Text to scan.
|
text: Text to scan.
|
||||||
|
mask: ``mask(original, label) -> str`` replacement builder.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(redacted_text, redaction_count) tuple.
|
(redacted_text, redaction_count) tuple.
|
||||||
@@ -82,12 +147,38 @@ def _redact_bare_hex_secrets(text: str) -> tuple:
|
|||||||
return match.group(0)
|
return match.group(0)
|
||||||
if _SECRET_CONTEXT_RE.search(window):
|
if _SECRET_CONTEXT_RE.search(window):
|
||||||
count += 1
|
count += 1
|
||||||
return '[HEX_KEY MASQUÉ]'
|
return mask(match.group(0), '[HEX_KEY MASQUÉ]')
|
||||||
return match.group(0)
|
return match.group(0)
|
||||||
|
|
||||||
return _HEX_RE.sub(_replace, text), count
|
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:
|
def redact(text: str) -> tuple:
|
||||||
"""Redact sensitive patterns from text.
|
"""Redact sensitive patterns from text.
|
||||||
|
|
||||||
@@ -97,22 +188,84 @@ def redact(text: str) -> tuple:
|
|||||||
Returns:
|
Returns:
|
||||||
(redacted_text, redaction_count) tuple.
|
(redacted_text, redaction_count) tuple.
|
||||||
"""
|
"""
|
||||||
count = 0
|
result, count = _redact(text, _plain_mask)
|
||||||
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
|
|
||||||
if count > 0:
|
if count > 0:
|
||||||
logger.info(f"Redacted {count} secret(s) from content")
|
logger.info(f"Redacted {count} secret(s) from content")
|
||||||
return result, count
|
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 ``<span>`` 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 '<unknown>'}")
|
||||||
|
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 ``<span class="secret-mask" data-secret="…">label</span>``
|
||||||
|
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 (
|
||||||
|
'<span class="secret-mask" data-secret="'
|
||||||
|
+ _html.escape(str(original), quote=True)
|
||||||
|
+ '">'
|
||||||
|
+ label_esc
|
||||||
|
+ "</span>"
|
||||||
|
)
|
||||||
|
|
||||||
|
return _PLACEHOLDER_RE.sub(_sub, text)
|
||||||
|
|
||||||
|
|
||||||
def redact_file_content(content: str, file_path: str = "") -> str:
|
def redact_file_content(content: str, file_path: str = "") -> str:
|
||||||
"""Redact a file's content for preview rendering.
|
"""Redact a file's content for preview rendering.
|
||||||
|
|
||||||
|
|||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.54.0"
|
version = "2.55.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.54.0"
|
version = "2.55.0"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.54.0",
|
"version": "2.55.0",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
@@ -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 |
|
| **Rate limiting MFA** | Appliqué aux endpoints TOTP/WebAuthn/recovery |
|
||||||
| **Audit log** | Écritures, suppressions, config dans `data/audit.log` (JSON lines, rotation 10 Mo) |
|
| **Audit log** | Écritures, suppressions, config dans `data/audit.log` (JSON lines, rotation 10 Mo) |
|
||||||
| **Backup automatique** | Avant chaque modification/suppression dans `.obsigate-backup/` |
|
| **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 |
|
| **CSP** | `object-src`, `base-uri`, `form-action`, `frame-ancestors` restreints |
|
||||||
| **Cookie HttpOnly** | Jeton retiré de `sessionStorage`, porté par cookie HTTP-only |
|
| **Cookie HttpOnly** | Jeton retiré de `sessionStorage`, porté par cookie HTTP-only |
|
||||||
| **Utilisateur non-root** | Conteneur sous `obsigate` (UID 1000) |
|
| **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
|
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.
|
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
|
## 7. Variables d'environnement
|
||||||
|
|||||||
+36
-1
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# 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
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **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 `<span class="secret-mask" data-secret>`
|
||||||
|
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)
|
## ⚪ Backlog — Priorité 4 (P4)
|
||||||
|
|
||||||
### 73. Synchronisation multi-appareils — Obsidian Sync compatible
|
### 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| BUG-047 | Versionnage — source unique `VERSION` + bump SemVer automatique au commit (hooks + tag) | 2.3.0 | [DEVELOPMENT_AND_RELEASES.md](./DEVELOPMENT_AND_RELEASES.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 `<span>` 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`) :
|
||||||
|
`<span class="secret-mask" data-secret="valeur">[CLÉ API MASQUÉE]</span>` —
|
||||||
|
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 `<pre><code>` 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.
|
||||||
+6
-2
@@ -5302,8 +5302,12 @@ curl -X POST https://votre-serveur.com/webhook \
|
|||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<strong>Secret redactor</strong><span data-i18n="help.desc_9846fc07"> : Masque
|
<strong>Secret redactor</strong><span data-i18n="help.desc_9846fc07"> : Masque
|
||||||
JWT, clés API, tokens GitHub dans les
|
JWT, mots de passe et clés API (OpenAI,
|
||||||
aperçus</span></li>
|
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</span></li>
|
||||||
<li>
|
<li>
|
||||||
<strong>Path traversal</strong> : Validation
|
<strong>Path traversal</strong> : Validation
|
||||||
des chemins contre les attaques
|
des chemins contre les attaques
|
||||||
|
|||||||
+24
-2
@@ -7276,7 +7276,10 @@ export function renderFile(data) {
|
|||||||
prettyBtn.classList.add("active");
|
prettyBtn.classList.add("active");
|
||||||
} else {
|
} else {
|
||||||
mdDiv.innerHTML = data.html;
|
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");
|
prettyBtn.classList.remove("active");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -7327,8 +7330,10 @@ export function renderFile(data) {
|
|||||||
renderBacklinksPanel(data.vault, data.path, area);
|
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) => {
|
area.querySelectorAll("pre code").forEach((block) => {
|
||||||
|
if (block.querySelector(".secret-mask")) return;
|
||||||
safeHighlight(block);
|
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();
|
safeCreateIcons();
|
||||||
area.scrollTop = 0;
|
area.scrollTop = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -1004,7 +1004,7 @@
|
|||||||
"help.desc_8f0bffe3": "JWT tokens",
|
"help.desc_8f0bffe3": "JWT tokens",
|
||||||
"help.desc_9186a3a3": "Explorez l'arborescence : Naviguez dans les dossiers",
|
"help.desc_9186a3a3": "Explorez l'arborescence : Naviguez dans les dossiers",
|
||||||
"help.desc_93902d18": "Utilisez les tags : Filtrez par tags pour affiner",
|
"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_985d759b": "Token unique : 64 caractères hexadécimaux, impossible à deviner",
|
||||||
"help.desc_991307d6": " — Generate content from the selection:",
|
"help.desc_991307d6": " — Generate content from the selection:",
|
||||||
"help.desc_995a711f": ": Opens the CodeMirror editor",
|
"help.desc_995a711f": ": Opens the CodeMirror editor",
|
||||||
@@ -2221,6 +2221,9 @@
|
|||||||
"viewer.export_html": "Export as HTML",
|
"viewer.export_html": "Export as HTML",
|
||||||
"viewer.export_md_bundle": "Export as Markdown bundle (.zip)",
|
"viewer.export_md_bundle": "Export as Markdown bundle (.zip)",
|
||||||
"viewer.export_start": "Exporting...",
|
"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.export_title": "Export document",
|
||||||
"viewer.forge_brand": "Forge",
|
"viewer.forge_brand": "Forge",
|
||||||
"viewer.forge_title": "Forge (new editor)",
|
"viewer.forge_title": "Forge (new editor)",
|
||||||
|
|||||||
@@ -1004,7 +1004,7 @@
|
|||||||
"help.desc_8f0bffe3": ": Tokens JWT\n avec mots de passe hachés Argon2id",
|
"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_9186a3a3": "Explorez l'arborescence :\n Naviguez dans les dossiers",
|
||||||
"help.desc_93902d18": "Utilisez les tags : Filtrez\n par tags pour affiner",
|
"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_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_991307d6": "— Génère du\n contenu à partir de la sélection :",
|
||||||
"help.desc_995a711f": ": Ouvre l'éditeur\n CodeMirror",
|
"help.desc_995a711f": ": Ouvre l'éditeur\n CodeMirror",
|
||||||
@@ -2221,6 +2221,9 @@
|
|||||||
"viewer.export_html": "Exporter en HTML",
|
"viewer.export_html": "Exporter en HTML",
|
||||||
"viewer.export_md_bundle": "Exporter en bundle Markdown (.zip)",
|
"viewer.export_md_bundle": "Exporter en bundle Markdown (.zip)",
|
||||||
"viewer.export_start": "Export en cours...",
|
"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.export_title": "Exporter le document",
|
||||||
"viewer.forge_brand": "Forge",
|
"viewer.forge_brand": "Forge",
|
||||||
"viewer.forge_title": "Forge (nouvel éditeur)",
|
"viewer.forge_title": "Forge (nouvel éditeur)",
|
||||||
|
|||||||
@@ -2324,6 +2324,18 @@ select {
|
|||||||
cursor: default;
|
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 placeholders */
|
||||||
.image-not-found {
|
.image-not-found {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"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.",
|
"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",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
@@ -845,6 +845,95 @@ class TestSecretRedactor:
|
|||||||
text = f"value {blob} end"
|
text = f"value {blob} end"
|
||||||
assert redact_file_content(text) == text
|
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)
|
# Static / PWA caching (Cloudflare / mobile freshness)
|
||||||
|
|||||||
Reference in New Issue
Block a user