From 4d9a9e7e2b34a0d15c90b77cdd79237c4afb49e2 Mon Sep 17 00:00:00 2001 From: PTah Date: Sat, 20 Jun 2026 19:58:54 +1000 Subject: [PATCH] fix: decode WinRM Cyrillic from CP866 and show deploy.log UTF-8 (0.20.17) --- backend/app/services/winrm_connect.py | 52 ++++++++++++++++++++++++--- backend/app/version.py | 2 +- backend/tests/test_winrm_connect.py | 20 +++++++++++ frontend/src/version.ts | 2 +- 4 files changed, 69 insertions(+), 7 deletions(-) diff --git a/backend/app/services/winrm_connect.py b/backend/app/services/winrm_connect.py index 07dd039..52836e3 100644 --- a/backend/app/services/winrm_connect.py +++ b/backend/app/services/winrm_connect.py @@ -19,7 +19,7 @@ _CLIXML_PROGRESS_NOISE = frozenset( } ) _CLIXML_MARKER = "#< CLIXML" - +_CYRILLIC_RE = re.compile(r"[\u0400-\u04FF]") RDP_REMOTE_STAGING = r"C:\ProgramData\RDP-login-monitor\_sac_staging" RDP_BUNDLE_FILES = ( "Login_Monitor.ps1", @@ -128,6 +128,39 @@ def _clixml_to_plain(text: str) -> str: return without +def _decode_text_score(text: str) -> int: + cyrillic = len(_CYRILLIC_RE.findall(text)) + suspicious = len(re.findall(r"\?{3,}", text)) + return cyrillic * 5 - text.count("\ufffd") * 20 - suspicious * 15 - max(0, text.count("?") - cyrillic) * 2 + + +def _decode_winrm_bytes(data: bytes) -> str: + if not data: + return "" + for encoding in ("utf-8-sig", "utf-8"): + try: + text = data.decode(encoding) + except UnicodeDecodeError: + break + score = _decode_text_score(text) + if text.isascii() or score > 0: + return text + best_score = -10_000 + best_text = "" + for encoding in ("cp866", "cp1251"): + try: + text = data.decode(encoding) + except UnicodeDecodeError: + continue + score = _decode_text_score(text) + if score > best_score: + best_score = score + best_text = text + if best_text: + return best_text + return data.decode("utf-8", errors="replace") + + def _winrm_failure_detail(stdout: str, stderr: str, exit_code: int) -> str: stdout_plain = _clixml_to_plain(stdout) stderr_plain = _clixml_to_plain(stderr) @@ -213,8 +246,8 @@ def run_winrm_ps( except Exception as exc: return WinRmCmdResult(ok=False, message=f"WinRM error ({target}): {exc}", target=target) - stdout = (result.std_out or b"").decode("utf-8", errors="replace") - stderr = (result.std_err or b"").decode("utf-8", errors="replace") + stdout = _decode_winrm_bytes(result.std_out or b"") + stderr = _decode_winrm_bytes(result.std_err or b"") exit_code = int(result.status_code) stdout, stderr = _sanitize_winrm_streams(stdout, stderr) return _finalize_winrm_run( @@ -260,8 +293,8 @@ def run_winrm_cmd( except Exception as exc: return WinRmCmdResult(ok=False, message=f"WinRM error ({target}): {exc}", target=target) - stdout = (result.std_out or b"").decode("utf-8", errors="replace") - stderr = (result.std_err or b"").decode("utf-8", errors="replace") + stdout = _decode_winrm_bytes(result.std_out or b"") + stderr = _decode_winrm_bytes(result.std_err or b"") exit_code = int(result.status_code) stdout, stderr = _sanitize_winrm_streams(stdout, stderr) return _finalize_winrm_run( @@ -292,6 +325,9 @@ def _powershell_script_prelude() -> str: return ( "$ProgressPreference = 'SilentlyContinue'\n" "$InformationPreference = 'SilentlyContinue'\n" + "$utf8NoBom = New-Object System.Text.UTF8Encoding $false\n" + "[Console]::OutputEncoding = $utf8NoBom\n" + "$OutputEncoding = $utf8NoBom\n" ) @@ -338,8 +374,14 @@ def _deploy_from_staging_body(staging_path: str) -> str: f"$staging = '{literal}'\n" "$deploy = Join-Path $staging 'Deploy-LoginMonitor.ps1'\n" "if (-not (Test-Path -LiteralPath $deploy)) { throw \"Deploy-LoginMonitor.ps1 missing in staging\" }\n" + "$logPath = Join-Path $env:ProgramData 'RDP-login-monitor\\Logs\\deploy.log'\n" "Write-Output \"Running: $deploy -SourceShareRoot $staging\"\n" "powershell.exe -NoProfile -ExecutionPolicy Bypass -File $deploy -SourceShareRoot $staging\n" + "if (Test-Path -LiteralPath $logPath) {\n" + " Write-Output ''\n" + " Write-Output '--- deploy.log ---'\n" + " Get-Content -LiteralPath $logPath -Encoding UTF8 | Select-Object -Last 60\n" + "}\n" ) diff --git a/backend/app/version.py b/backend/app/version.py index 2417d73..8af1250 100644 --- a/backend/app/version.py +++ b/backend/app/version.py @@ -1,5 +1,5 @@ """Единый источник версии SAC (API, health, логи, OpenAPI).""" APP_NAME = "Security Alert Center" -APP_VERSION = "0.20.16" +APP_VERSION = "0.20.17" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" diff --git a/backend/tests/test_winrm_connect.py b/backend/tests/test_winrm_connect.py index cbf484e..222edab 100644 --- a/backend/tests/test_winrm_connect.py +++ b/backend/tests/test_winrm_connect.py @@ -15,6 +15,7 @@ from app.services.winrm_connect import ( WinRmCmdResult, _clixml_to_plain, _custom_deploy_body, + _decode_winrm_bytes, _deploy_from_staging_body, _download_bundle_body, _prepare_staging_body, @@ -23,6 +24,25 @@ from app.services.winrm_connect import ( ) +def test_decode_winrm_bytes_reads_russian_cp866_console(): + sample = "Деплой: корень шары".encode("cp866") + decoded = _decode_winrm_bytes(sample) + assert "Деплой" in decoded + assert "шары" in decoded + + +def test_decode_winrm_bytes_handles_utf8_multibyte(): + text = "\u0414\u0435\u043f\u043b\u043e\u0439: \u043a\u043e\u0440\u0435\u043d\u044c \u0448\u0430\u0440\u044b" + assert _decode_winrm_bytes(text.encode("utf-8")) == text + + +def test_deploy_from_staging_includes_deploy_log_tail(): + script = _deploy_from_staging_body(RDP_REMOTE_STAGING) + assert "deploy.log" in script + assert "Get-Content" in script + assert "-Encoding UTF8" in script + + def test_prepare_staging_recreates_remote_dir(): script = _prepare_staging_body(RDP_REMOTE_STAGING) assert "Remove-Item" in script diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 6eed04c..79875d5 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1,4 +1,4 @@ /** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */ export const APP_NAME = "Security Alert Center"; -export const APP_VERSION = "0.20.16"; +export const APP_VERSION = "0.20.17"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;