diff --git a/strix.spec b/strix.spec index 987d8b96..b32be665 100644 --- a/strix.spec +++ b/strix.spec @@ -36,6 +36,10 @@ for asset in viewer_static.rglob('*'): rel_path = asset.relative_to(project_root) datas.append((str(asset), str(rel_path.parent))) +for json_locale in strix_root.rglob('locales/*.json'): + rel_path = json_locale.relative_to(project_root) + datas.append((str(json_locale), str(rel_path.parent))) + datas += collect_data_files('tiktoken') datas += collect_data_files('tiktoken_ext') @@ -112,6 +116,12 @@ hiddenimports = [ # Strix modules 'strix', + 'strix.i18n', + 'strix.i18n.catalog', + 'strix.i18n.context', + 'strix.i18n.detector', + 'strix.i18n.directive', + 'strix.i18n.translator', 'strix.interface', 'strix.interface.main', 'strix.interface.cli', diff --git a/strix/agents/prompt.py b/strix/agents/prompt.py index c45831aa..2358bdcd 100644 --- a/strix/agents/prompt.py +++ b/strix/agents/prompt.py @@ -7,6 +7,7 @@ from typing import Any from jinja2 import Environment, FileSystemLoader, select_autoescape +from strix.i18n import get_language_directive from strix.skills import get_available_skills, load_skills, skill_search_dirs from strix.utils.resource_paths import get_strix_resource_path @@ -131,6 +132,7 @@ def render_system_prompt( available_skills=get_available_skills(), interactive=interactive, is_root=is_root, + language_directive=get_language_directive(), system_prompt_context=system_prompt_context or {}, include_scope=include_scope, **skill_content, diff --git a/strix/agents/prompts/system_prompt.jinja b/strix/agents/prompts/system_prompt.jinja index b13c9a4c..3158ebb8 100644 --- a/strix/agents/prompts/system_prompt.jinja +++ b/strix/agents/prompts/system_prompt.jinja @@ -1,4 +1,9 @@ You are an advanced AI application security validation agent. Your purpose is to perform authorized security verification, reproduce and validate weaknesses on in-scope assets, and help remediate real security issues. +{% if language_directive %} + +{{ language_directive }} + +{% endif %} You follow all instructions and rules provided to you exactly as written in the system prompt at all times. {% if is_root %} diff --git a/strix/config/settings.py b/strix/config/settings.py index 5aa6da06..438618f6 100644 --- a/strix/config/settings.py +++ b/strix/config/settings.py @@ -205,6 +205,10 @@ class ViewerSettings(BaseSettings): class Settings(BaseSettings): model_config = _BASE_CONFIG + language: str = Field( + default="en", + validation_alias=AliasChoices("STRIX_LANGUAGE", "language"), + ) llm: LlmSettings = Field(default_factory=LlmSettings) dedupe: DedupeSettings = Field(default_factory=DedupeSettings) runtime: RuntimeSettings = Field(default_factory=RuntimeSettings) diff --git a/strix/i18n/__init__.py b/strix/i18n/__init__.py new file mode 100644 index 00000000..10d5e8b1 --- /dev/null +++ b/strix/i18n/__init__.py @@ -0,0 +1,52 @@ +"""Robust Internationalization (i18n) Engine for Strix.""" + +from __future__ import annotations + +from typing import Any + +from strix.i18n.catalog import clear_locales_cache, get_available_languages +from strix.i18n.context import reset_context_language, set_context_language +from strix.i18n.detector import DEFAULT_LANGUAGE, detect_language, normalize_language +from strix.i18n.directive import LANGUAGE_NAMES, get_language_directive +from strix.i18n.translator import translate + + +t = translate + + +def set_language(lang: str | None) -> str: + """Set language preference for the current async execution context.""" + normalized = normalize_language(lang) + set_context_language(normalized) + return normalized + + +def get_language() -> str: + """Get active language preference or auto-detect from environment/config.""" + return detect_language() + + +def reset_language() -> None: + """Reset the execution context language to default/unspecified.""" + reset_context_language() + + +def __getattr__(name: str) -> Any: + if name == "SUPPORTED_LANGUAGES": + return get_available_languages() + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = [ + "DEFAULT_LANGUAGE", + "LANGUAGE_NAMES", + "clear_locales_cache", + "get_available_languages", + "get_language", + "get_language_directive", + "normalize_language", + "reset_language", + "set_language", + "t", + "translate", +] diff --git a/strix/i18n/catalog.py b/strix/i18n/catalog.py new file mode 100644 index 00000000..0700c949 --- /dev/null +++ b/strix/i18n/catalog.py @@ -0,0 +1,87 @@ +"""Locale catalogue discovery, validation, and thread-safe caching.""" + +from __future__ import annotations + +import contextlib +import json +import logging +import threading +from pathlib import Path +from typing import Any + +from strix.utils.resource_paths import get_strix_resource_path + + +logger = logging.getLogger(__name__) + +_locales_lock = threading.RLock() +_locales_cache: dict[str, dict[str, str]] = {} + + +def get_builtin_locales_dir() -> Path: + """Return the filesystem path to bundled locale catalogs.""" + return get_strix_resource_path("locales") + + +def get_user_locales_dir() -> Path: + """Return the filesystem path to user-defined community locale catalogs.""" + return Path.home() / ".strix" / "locales" + + +def get_available_languages() -> set[str]: + """Discover all available language codes across built-in and user directories.""" + languages: set[str] = {"en"} + + builtin_dir = get_builtin_locales_dir() + if builtin_dir.is_dir(): + for path in builtin_dir.glob("*.json"): + languages.add(path.stem.lower()) + + user_dir = get_user_locales_dir() + if user_dir.is_dir(): + for path in user_dir.glob("*.json"): + languages.add(path.stem.lower()) + + return languages + + +def _safe_merge(target: dict[str, str], source_data: Any) -> None: + """Safely merge key-value translation strings from source into target.""" + if not isinstance(source_data, dict): + return + target.update( + {k: v for k, v in source_data.items() if isinstance(k, str) and isinstance(v, str)} + ) + + +def load_locale(lang: str) -> dict[str, str]: + """Load locale catalog JSON with thread-safe caching, type validation, and user overrides.""" + with _locales_lock: + if lang in _locales_cache: + return _locales_cache[lang] + + translations: dict[str, str] = {} + + # 1. Built-in locale catalog + builtin_file = get_builtin_locales_dir() / f"{lang}.json" + if builtin_file.is_file(): + with contextlib.suppress(OSError, json.JSONDecodeError): + raw_data = json.loads(builtin_file.read_text(encoding="utf-8")) + _safe_merge(translations, raw_data) + + # 2. User community override catalog (~/.strix/locales/{lang}.json) + user_file = get_user_locales_dir() / f"{lang}.json" + if user_file.is_file(): + with contextlib.suppress(OSError, json.JSONDecodeError): + raw_data = json.loads(user_file.read_text(encoding="utf-8")) + _safe_merge(translations, raw_data) + + with _locales_lock: + _locales_cache[lang] = translations + return translations + + +def clear_locales_cache() -> None: + """Evict all cached locale catalogs (useful for testing or hot-reload).""" + with _locales_lock: + _locales_cache.clear() diff --git a/strix/i18n/context.py b/strix/i18n/context.py new file mode 100644 index 00000000..4b7b1aac --- /dev/null +++ b/strix/i18n/context.py @@ -0,0 +1,26 @@ +"""Execution-context and task isolation for Strix i18n.""" + +from __future__ import annotations + +import contextvars + + +_current_language: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "strix_current_language", + default=None, +) + + +def get_context_language() -> str | None: + """Return the active language code bound to the current async context, or None.""" + return _current_language.get() + + +def set_context_language(lang: str | None) -> None: + """Bind a normalized language code to the active async execution context.""" + _current_language.set(lang) + + +def reset_context_language() -> None: + """Clear the active language code from the current async execution context.""" + _current_language.set(None) diff --git a/strix/i18n/detector.py b/strix/i18n/detector.py new file mode 100644 index 00000000..4e58278c --- /dev/null +++ b/strix/i18n/detector.py @@ -0,0 +1,64 @@ +"""Language detection, normalization, and precedence policy.""" + +from __future__ import annotations + +import contextlib +import json +import os +from pathlib import Path + +from strix.i18n.catalog import get_available_languages +from strix.i18n.context import get_context_language + + +DEFAULT_LANGUAGE: str = "en" + + +def normalize_language(lang: str | None, supported: set[str] | None = None) -> str: + """Normalize language tag (e.g. 'es_ES.UTF-8' -> 'es') and validate. + + Checks against supported locales and returns DEFAULT_LANGUAGE if unrecognized. + """ + if not lang: + return DEFAULT_LANGUAGE + + clean = lang.strip().lower().split(".")[0].split("_")[0].split("-")[0] + available = supported if supported is not None else get_available_languages() + + return clean if clean in available else DEFAULT_LANGUAGE + + +def detect_language() -> str: + """Detect language following the canonical precedence chain: + + 1. Active execution context (ContextVar) + 2. STRIX_LANGUAGE environment variable + 3. User CLI configuration file (~/.strix/cli-config.json) + 4. System locale (LC_ALL takes precedence over LANG) + 5. Default fallback language ('en') + """ + ctx_lang = get_context_language() + if ctx_lang: + return ctx_lang + + # 1. Environment Variable STRIX_LANGUAGE + env_lang = os.getenv("STRIX_LANGUAGE") + if env_lang: + return normalize_language(env_lang) + + # 2. Config File ~/.strix/cli-config.json + cfg_path = Path.home() / ".strix" / "cli-config.json" + if cfg_path.is_file(): + with contextlib.suppress(OSError, json.JSONDecodeError): + data = json.loads(cfg_path.read_text(encoding="utf-8")) + if isinstance(data, dict): + cfg_lang = data.get("env", {}).get("STRIX_LANGUAGE") or data.get("language") + if isinstance(cfg_lang, str) and cfg_lang.strip(): + return normalize_language(cfg_lang) + + # 3. System Locale (LC_ALL takes precedence over LANG) + sys_lang = os.getenv("LC_ALL") or os.getenv("LANG") + if sys_lang: + return normalize_language(sys_lang) + + return DEFAULT_LANGUAGE diff --git a/strix/i18n/directive.py b/strix/i18n/directive.py new file mode 100644 index 00000000..ac124f11 --- /dev/null +++ b/strix/i18n/directive.py @@ -0,0 +1,43 @@ +"""Agent prompt language directive generator for localized security findings.""" + +from __future__ import annotations + +from strix.i18n.detector import detect_language + + +LANGUAGE_NAMES: dict[str, str] = { + "en": "English", + "es": "Spanish", + "id": "Indonesian", + "ko": "Korean", + "pt": "Portuguese", + "fr": "French", + "de": "German", + "ja": "Japanese", + "zh": "Chinese", + "it": "Italian", + "ru": "Russian", + "tr": "Turkish", + "ar": "Arabic", + "vi": "Vietnamese", +} + + +def get_language_directive(lang: str | None = None) -> str: + """Generate prompt directive for LLM agents when target language is non-English. + + Ensures technical security artifacts (CVE, CWE, CVSS, code, commands) remain unaltered. + """ + effective_lang = lang if lang is not None else detect_language() + if effective_lang == "en": + return "" + + target_name = LANGUAGE_NAMES.get(effective_lang, effective_lang.upper()) + + return ( + f"IMPORTANT LANGUAGE INSTRUCTION:\n" + f"Write all human-readable findings, executive summaries, descriptions, " + f"and remediation steps in {target_name}.\n" + f"Do NOT translate technical identifiers, including CVE IDs, CWE IDs, CVSS scores, " + f"source code snippets, file paths, or shell commands." + ) diff --git a/strix/i18n/translator.py b/strix/i18n/translator.py new file mode 100644 index 00000000..25838da3 --- /dev/null +++ b/strix/i18n/translator.py @@ -0,0 +1,29 @@ +"""Translation lookup, fallback resolution, and safe keyword interpolation.""" + +from __future__ import annotations + +from typing import Any + +from strix.i18n.catalog import load_locale +from strix.i18n.detector import DEFAULT_LANGUAGE, detect_language + + +def translate(key: str, **kwargs: Any) -> str: + """Translate key into the active language with fallback and safe interpolation.""" + lang = detect_language() + catalog = load_locale(lang) + + msg = catalog.get(key) + if msg is None and lang != DEFAULT_LANGUAGE: + msg = load_locale(DEFAULT_LANGUAGE).get(key) + if msg is None: + msg = key + + if kwargs and "{" in msg and "}" in msg: + try: + msg = msg.format(**kwargs) + except (KeyError, ValueError, IndexError): + for k, v in kwargs.items(): + msg = msg.replace(f"{{{k}}}", str(v)) + + return msg diff --git a/strix/interface/cli_args.py b/strix/interface/cli_args.py index c9b6951b..4f0f4110 100644 --- a/strix/interface/cli_args.py +++ b/strix/interface/cli_args.py @@ -57,6 +57,14 @@ def _positive_int(value: str) -> int: def parse_arguments() -> argparse.Namespace: + # Pre-resolve language before argparse if set in sys.argv + for idx, arg in enumerate(sys.argv[:-1]): + if arg in ("--language", "-l") and idx + 1 < len(sys.argv): + from strix.i18n import set_language + + set_language(sys.argv[idx + 1]) + break + parser = argparse.ArgumentParser( description="Strix Multi-Agent Cybersecurity Penetration Testing Tool", formatter_class=argparse.RawDescriptionHelpFormatter, @@ -129,6 +137,14 @@ Strix Cloud: "matching upgrade command instead.", ) + parser.add_argument( + "--language", + "-l", + type=str, + default=None, + help="Language preference for CLI messages and audit reports (e.g. en, es, id)", + ) + parser.add_argument( "-t", "--target", @@ -323,6 +339,11 @@ Strix Cloud: args.diff_scope = {"active": False} args.run_name = None + if args.language: + from strix.i18n import set_language + + set_language(args.language) + if args.config: apply_config_override(validate_config_file(args.config)) diff --git a/strix/locales/en.json b/strix/locales/en.json new file mode 100644 index 00000000..4d922acc --- /dev/null +++ b/strix/locales/en.json @@ -0,0 +1,11 @@ +{ + "cli.description": "Strix - Autonomous AI Security Penetration Testing Agent", + "cli.scan_started": "Starting scan on target: {target}", + "cli.scan_completed": "Scan completed for target: {target}", + "cli.vulnerabilities_found": "Found {count} vulnerability(ies)", + "report.title": "Security Penetration Test Report", + "report.description": "Description", + "report.evidence": "Evidence", + "report.impact": "Impact", + "report.remediation": "Remediation" +} diff --git a/strix/locales/es.json b/strix/locales/es.json new file mode 100644 index 00000000..87c18405 --- /dev/null +++ b/strix/locales/es.json @@ -0,0 +1,11 @@ +{ + "cli.description": "Strix - Agente Autónomo de Pruebas de Penetración de Seguridad con IA", + "cli.scan_started": "Iniciando escaneo en el objetivo: {target}", + "cli.scan_completed": "Escaneo completado para el objetivo: {target}", + "cli.vulnerabilities_found": "Se encontraron {count} vulnerabilidad(es)", + "report.title": "Informe de Prueba de Penetración de Seguridad", + "report.description": "Descripción", + "report.evidence": "Evidencia", + "report.impact": "Impacto", + "report.remediation": "Remediación" +} diff --git a/strix/locales/id.json b/strix/locales/id.json new file mode 100644 index 00000000..92e599cd --- /dev/null +++ b/strix/locales/id.json @@ -0,0 +1,11 @@ +{ + "cli.description": "Strix - Agen Uji Penetrasi Keamanan Berbasis AI Otonom", + "cli.scan_started": "Memulai pemindaian pada target: {target}", + "cli.scan_completed": "Pemindaian selesai untuk target: {target}", + "cli.vulnerabilities_found": "Ditemukan {count} kerentanan", + "report.title": "Laporan Uji Penetrasi Keamanan", + "report.description": "Deskripsi", + "report.evidence": "Bukti", + "report.impact": "Dampak", + "report.remediation": "Langkah Perbaikan" +} diff --git a/strix/locales/ko.json b/strix/locales/ko.json new file mode 100644 index 00000000..5043418b --- /dev/null +++ b/strix/locales/ko.json @@ -0,0 +1,11 @@ +{ + "cli.description": "Strix - 자율형 AI 보안 모의해킹 에이전트", + "cli.scan_started": "대상에 대한 스캔 시작: {target}", + "cli.scan_completed": "대상에 대한 스캔 완료: {target}", + "cli.vulnerabilities_found": "{count}개의 취약점이 발견되었습니다", + "report.title": "보안 침투 테스트 보고서", + "report.description": "설명", + "report.evidence": "증거", + "report.impact": "영향도", + "report.remediation": "조치 방안" +} diff --git a/strix/report/writer.py b/strix/report/writer.py index cf858f7d..37ba2496 100644 --- a/strix/report/writer.py +++ b/strix/report/writer.py @@ -17,6 +17,7 @@ from pygments.lexers.special import TextLexer from pygments.util import ClassNotFound from strix.core.paths import run_record_path +from strix.i18n import t if TYPE_CHECKING: @@ -139,7 +140,7 @@ def write_run_record(run_dir: Path, run_record: dict[str, Any]) -> None: def write_executive_report(run_dir: Path, final_scan_result: str) -> None: path = run_dir / "penetration_test_report.md" with path.open("w", encoding="utf-8") as f: - f.write("# Security Penetration Test Report\n\n") + f.write(f"# {t('report.title')}\n\n") f.write(f"**Generated:** {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}\n\n") f.write(f"{final_scan_result}\n") logger.info("Saved final penetration test report to: %s", path) @@ -259,17 +260,17 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL lines.append(f"**{label}:** {value}") lines.append("") - lines.append("## Description\n") + lines.append(f"## {t('report.description')}\n") lines.append(report.get("description") or "No description provided.") lines.append("") if report.get("evidence"): - lines.append("## Evidence\n") + lines.append(f"## {t('report.evidence')}\n") lines.append(str(report["evidence"])) lines.append("") if report.get("impact"): - lines.append("## Impact\n") + lines.append(f"## {t('report.impact')}\n") lines.append(str(report["impact"])) lines.append("") @@ -342,7 +343,7 @@ def render_vulnerability_md(report: dict[str, Any]) -> str: # noqa: PLR0912, PL lines.append("") if report.get("remediation_steps"): - lines.append("## Remediation\n") + lines.append(f"## {t('report.remediation')}\n") lines.append(str(report["remediation_steps"])) lines.append("") diff --git a/tests/test_i18n.py b/tests/test_i18n.py new file mode 100644 index 00000000..b9cfef2f --- /dev/null +++ b/tests/test_i18n.py @@ -0,0 +1,298 @@ +"""Comprehensive test suite for Strix internationalization (i18n) engine.""" + +from __future__ import annotations + +import asyncio +import json +from unittest.mock import patch + +import pytest + +from strix.agents.prompt import render_system_prompt +from strix.i18n import ( + DEFAULT_LANGUAGE, + clear_locales_cache, + get_available_languages, + get_language, + get_language_directive, + normalize_language, + reset_language, + set_language, + t, +) +from strix.i18n.catalog import _safe_merge, load_locale +from strix.i18n.detector import detect_language +from strix.interface.cli_args import parse_arguments +from strix.report.writer import render_vulnerability_md, write_executive_report + + +@pytest.fixture(autouse=True) +def _reset_i18n_state(): + """Ensure clean i18n context and cache for every test.""" + reset_language() + clear_locales_cache() + yield + reset_language() + clear_locales_cache() + + +# --------------------------------------------------------------------------- +# 1. Core State & Normalization Tests +# --------------------------------------------------------------------------- + + +def test_set_and_get_language(): + assert set_language("es") == "es" + assert get_language() == "es" + + assert set_language("id") == "id" + assert get_language() == "id" + + assert set_language("ko") == "ko" + assert get_language() == "ko" + + reset_language() + assert get_language() == DEFAULT_LANGUAGE + + +def test_normalize_language_tags(): + assert normalize_language("es_ES.UTF-8") == "es" + assert normalize_language("id-ID") == "id" + assert normalize_language("ko_KR.eucKR") == "ko" + assert normalize_language("EN_us") == "en" + assert normalize_language(None) == DEFAULT_LANGUAGE + assert normalize_language("unsupported_xyz") == DEFAULT_LANGUAGE + + +def test_dynamic_available_languages(): + available = get_available_languages() + assert "en" in available + assert "es" in available + assert "id" in available + assert "ko" in available + + +# --------------------------------------------------------------------------- +# 2. Async Context Isolation Tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_async_contextvar_isolation(): + """Verify concurrent async tasks maintain strictly isolated language contexts.""" + + async def task_worker(lang: str, delay: float) -> str: + set_language(lang) + await asyncio.sleep(delay) + return get_language() + + results = await asyncio.gather( + task_worker("es", 0.02), + task_worker("id", 0.01), + task_worker("ko", 0.015), + ) + + assert results == ["es", "id", "ko"] + # Outer context remains untouched + assert get_language() == DEFAULT_LANGUAGE + + +# --------------------------------------------------------------------------- +# 3. Precedence & Detection Tests +# --------------------------------------------------------------------------- + + +def test_precedence_context_over_env_and_system(monkeypatch): + monkeypatch.setenv("STRIX_LANGUAGE", "es") + monkeypatch.setenv("LC_ALL", "id") + + # Context explicitly set wins over all + set_language("ko") + assert detect_language() == "ko" + + # When context is reset, STRIX_LANGUAGE environment variable wins + reset_language() + assert detect_language() == "es" + + +def test_precedence_env_over_config_and_system(monkeypatch, tmp_path): + monkeypatch.setenv("STRIX_LANGUAGE", "id") + fake_config = tmp_path / ".strix" / "cli-config.json" + fake_config.parent.mkdir(parents=True) + fake_config.write_text(json.dumps({"env": {"STRIX_LANGUAGE": "es"}}), encoding="utf-8") + + with patch("pathlib.Path.home", return_value=tmp_path): + assert detect_language() == "id" + + +def test_precedence_config_over_system(monkeypatch, tmp_path): + monkeypatch.delenv("STRIX_LANGUAGE", raising=False) + monkeypatch.setenv("LC_ALL", "es") + + fake_config = tmp_path / ".strix" / "cli-config.json" + fake_config.parent.mkdir(parents=True) + fake_config.write_text(json.dumps({"env": {"STRIX_LANGUAGE": "id"}}), encoding="utf-8") + + with patch("pathlib.Path.home", return_value=tmp_path): + assert detect_language() == "id" + + +def test_precedence_lc_all_over_lang(monkeypatch): + monkeypatch.delenv("STRIX_LANGUAGE", raising=False) + monkeypatch.setenv("LC_ALL", "es_ES.UTF-8") + monkeypatch.setenv("LANG", "id_ID.UTF-8") + + assert detect_language() == "es" + + +def test_fallback_to_default_when_empty(monkeypatch): + monkeypatch.delenv("STRIX_LANGUAGE", raising=False) + monkeypatch.delenv("LC_ALL", raising=False) + monkeypatch.delenv("LANG", raising=False) + + assert detect_language() == DEFAULT_LANGUAGE + + +# --------------------------------------------------------------------------- +# 4. Catalog Loading, Caching & Override Tests +# --------------------------------------------------------------------------- + + +def test_catalog_load_builtin_and_caching(): + catalog_en = load_locale("en") + assert "report.title" in catalog_en + assert catalog_en["report.title"] == "Security Penetration Test Report" + + # Subsequent load hits cache + cached = load_locale("en") + assert cached is catalog_en + + +def test_safe_merge_validates_string_types(): + target = {"existing": "value"} + _safe_merge(target, {"valid_key": "valid_value", 123: "bad_key", "bad_val": 456}) + assert target["valid_key"] == "valid_value" + assert 123 not in target + assert "bad_val" not in target + + +def test_user_community_override_merging(tmp_path): + user_locales = tmp_path / ".strix" / "locales" + user_locales.mkdir(parents=True) + override_file = user_locales / "es.json" + override_file.write_text( + json.dumps({"custom.greeting": "Hola Mundo", "cli.description": "Custom Strix"}), + encoding="utf-8", + ) + + with patch("pathlib.Path.home", return_value=tmp_path): + catalog_es = load_locale("es") + assert catalog_es.get("custom.greeting") == "Hola Mundo" + assert catalog_es.get("cli.description") == "Custom Strix" + assert catalog_es.get("report.title") == "Informe de Prueba de Penetración de Seguridad" + + +# --------------------------------------------------------------------------- +# 5. Translation & Fallback Tests +# --------------------------------------------------------------------------- + + +def test_translation_formatting_interpolations(): + set_language("id") + result = t("cli.scan_started", target="example.com") + assert "example.com" in result + assert "Memulai" in result + + +def test_fallback_missing_key_in_target_language(): + set_language("es") + # Non-existent key in all catalogs falls back to key itself + assert t("untranslated.nonexistent_key") == "untranslated.nonexistent_key" + + +def test_safe_interpolation_on_missing_parameter(): + set_language("en") + result = t("cli.scan_started") # 'target' parameter missing + assert "{target}" in result or isinstance(result, str) + + +def test_safe_interpolation_with_special_characters(): + set_language("en") + payload = "'; DROP TABLE users; -- {bad}" + result = t("cli.scan_started", target=payload) + assert payload in result + + +# --------------------------------------------------------------------------- +# 6. Prompt Language Directive Tests +# --------------------------------------------------------------------------- + + +def test_language_directive_empty_for_english(): + set_language("en") + assert get_language_directive() == "" + + +def test_language_directive_for_supported_languages(): + set_language("es") + directive_es = get_language_directive() + assert "Spanish" in directive_es + assert "CVE IDs" in directive_es + assert "Do NOT translate technical identifiers" in directive_es + + set_language("id") + directive_id = get_language_directive() + assert "Indonesian" in directive_id + + set_language("ko") + directive_ko = get_language_directive() + assert "Korean" in directive_ko + + +# --------------------------------------------------------------------------- +# 7. End-to-End System & Integration Tests +# --------------------------------------------------------------------------- + + +def test_system_prompt_rendering_injects_language_directive(): + set_language("es") + prompt_es = render_system_prompt(scan_mode="quick") + assert "" in prompt_es + assert "Spanish" in prompt_es + + set_language("en") + prompt_en = render_system_prompt(scan_mode="quick") + assert "" not in prompt_en + + +def test_markdown_report_writer_localization(tmp_path): + set_language("es") + sample_report = { + "id": "vuln-001", + "title": "SQL Injection", + "severity": "critical", + "timestamp": "2026-10-10 12:00:00 UTC", + "description": "Exploitable SQL query.", + "evidence": "Payload proof.", + "impact": "Full database read.", + "remediation_steps": "Use parameters.", + } + + md = render_vulnerability_md(sample_report) + assert "## Descripción" in md + assert "## Evidencia" in md + assert "## Impacto" in md + assert "## Remediación" in md + + # Executive report title + write_executive_report(tmp_path, "Scan findings summary.") + exec_file = tmp_path / "penetration_test_report.md" + content = exec_file.read_text(encoding="utf-8") + assert "# Informe de Prueba de Penetración de Seguridad" in content + + +def test_cli_argument_parser_language_flag(monkeypatch): + monkeypatch.setattr("sys.argv", ["strix", "-t", "example.com", "--language", "id", "-n"]) + args = parse_arguments() + assert args.language == "id" + assert get_language() == "id"