Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 24 additions & 10 deletions sanctions/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
Sprint 1: Name screening against OpenSanctions (yente) with LLM enhancement.
"""

import html as html_lib
import json
import os

Expand Down Expand Up @@ -70,6 +71,9 @@ async def search_ui(request: Request, q: str = "", threshold: float = 0.7):
error = None
raw_json = ""

def escape_text(value: object) -> str:
return html_lib.escape(str(value), quote=True)

if q:
try:
results = await _query_yente(q, threshold)
Expand Down Expand Up @@ -106,17 +110,20 @@ def dataset_badge(ds: str) -> str:
"peps": ("bg-orange-100 text-orange-800", "PEP"),
}
cls, label = colors.get(ds, ("bg-gray-100 text-gray-700", ds.upper()))
return f'<span class="inline-block px-2 py-0.5 rounded text-xs font-semibold {cls}">{label}</span>'
return (
f'<span class="inline-block px-2 py-0.5 rounded text-xs font-semibold {cls}">'
f"{escape_text(label)}</span>"
)

def prop_row(key: str, vals: list) -> str:
if not vals:
return ""
joined = " · ".join(str(v) for v in vals[:5])
joined = " · ".join(escape_text(v) for v in vals[:5])
if len(vals) > 5:
joined += f" <span class='text-gray-400'>+{len(vals)-5} weitere</span>"
return f"""
<tr class="border-b border-gray-100">
<td class="py-1 pr-3 text-xs text-gray-500 font-medium whitespace-nowrap align-top">{key}</td>
<td class="py-1 pr-3 text-xs text-gray-500 font-medium whitespace-nowrap align-top">{escape_text(key)}</td>
<td class="py-1 text-xs text-gray-800 break-words">{joined}</td>
</tr>"""

Expand All @@ -130,13 +137,15 @@ def prop_row(key: str, vals: list) -> str:
color = score_color(m["score"])
props = m.get("properties", {})
rows = "".join(prop_row(k, props.get(k, [])) for k in SHOW_PROPS if props.get(k))
safe_name = escape_text(m["name"])
safe_id = escape_text(m["id"])

match_cards += f"""
<div class="bg-white rounded-xl border border-gray-200 shadow-sm p-5 mb-4">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-base font-semibold text-gray-900">{m['name']}</h3>
<p class="text-xs text-gray-400 mt-0.5">ID: {m['id']}</p>
<h3 class="text-base font-semibold text-gray-900">{safe_name}</h3>
<p class="text-xs text-gray-400 mt-0.5">ID: {safe_id}</p>
</div>
<div class="text-right shrink-0">
<div class="text-2xl font-bold" style="color:{color}">{score_pct}%</div>
Expand All @@ -156,22 +165,27 @@ def prop_row(key: str, vals: list) -> str:
{match_cards if matches else ""}"""

if error:
result_section = f'<div class="mt-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">⚠️ {error}</div>'
result_section = (
'<div class="mt-4 p-3 bg-red-50 border border-red-200 rounded text-red-700 text-sm">'
f"⚠️ {escape_text(error)}</div>"
)

json_section = ""
if raw_json:
safe_raw_json = escape_text(raw_json)
json_section = f"""
<div class="mt-6">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wide mb-2">JSON Output</h2>
<pre class="bg-gray-900 text-green-400 rounded-xl p-4 text-xs overflow-x-auto leading-relaxed">{raw_json}</pre>
<pre class="bg-gray-900 text-green-400 rounded-xl p-4 text-xs overflow-x-auto leading-relaxed">{safe_raw_json}</pre>
</div>"""

safe_query = escape_text(q)
threshold_options = "".join(
f'<option value="{v}" {"selected" if abs(threshold - v) < 0.01 else ""}>{int(v*100)}%</option>'
for v in [0.5, 0.6, 0.7, 0.8, 0.9]
)

html = f"""<!DOCTYPE html>
page_html = f"""<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
Expand Down Expand Up @@ -199,7 +213,7 @@ def prop_row(key: str, vals: list) -> str:
<input
type="text"
name="q"
value="{q}"
value="{safe_query}"
placeholder="Name eingeben, z.B. Wladimir Putin …"
autofocus
class="flex-1 rounded-xl border border-gray-300 px-4 py-2.5 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-red-500"
Expand Down Expand Up @@ -231,7 +245,7 @@ def prop_row(key: str, vals: list) -> str:
</body>
</html>"""

return HTMLResponse(content=html)
return HTMLResponse(content=page_html)


@app.post("/api/screen", response_model=ScreenResponse)
Expand Down
26 changes: 26 additions & 0 deletions sanctions/tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
Sprint 1: Grundlegende Unit-Tests ohne externe Services.
"""

import html
from unittest.mock import AsyncMock, patch

import httpx
Expand Down Expand Up @@ -36,6 +37,31 @@ def test_search_ui_with_query():
assert "text/html" in response.headers["content-type"]


def test_search_ui_escapes_query_and_result_fields():
"""Query und Trefferdaten werden HTML-escaped gerendert."""
payload = '\"><script>alert(1)</script>'
mock_results = [
{
"id": "<id>",
"caption": "<b>Bad</b>",
"score": 0.9,
"datasets": ["sanctions"],
"properties": {"notes": ['<img src=x onerror=alert(1)>']},
}
]

with patch("sanctions.src.main._query_yente", new=AsyncMock(return_value=mock_results)):
response = client.get("/search", params={"q": payload})

assert response.status_code == 200
assert payload not in response.text
assert "<b>Bad</b>" not in response.text
assert "<img src=x onerror=alert(1)>" not in response.text
assert html.escape(payload, quote=True) in response.text
assert "&lt;b&gt;Bad&lt;/b&gt;" in response.text
assert "&lt;img src=x onerror=alert(1)&gt;" in response.text


@pytest.mark.asyncio
async def test_screen_endpoint_mocked():
"""POST /api/screen mit gemocktem yente-Aufruf."""
Expand Down
Loading