fix: decode WinRM Cyrillic from CP866 and show deploy.log UTF-8 (0.20.17)

This commit is contained in:
2026-06-20 19:58:54 +10:00
parent 9484160045
commit 4d9a9e7e2b
4 changed files with 69 additions and 7 deletions
+47 -5
View File
@@ -19,7 +19,7 @@ _CLIXML_PROGRESS_NOISE = frozenset(
} }
) )
_CLIXML_MARKER = "#< CLIXML" _CLIXML_MARKER = "#< CLIXML"
_CYRILLIC_RE = re.compile(r"[\u0400-\u04FF]")
RDP_REMOTE_STAGING = r"C:\ProgramData\RDP-login-monitor\_sac_staging" RDP_REMOTE_STAGING = r"C:\ProgramData\RDP-login-monitor\_sac_staging"
RDP_BUNDLE_FILES = ( RDP_BUNDLE_FILES = (
"Login_Monitor.ps1", "Login_Monitor.ps1",
@@ -128,6 +128,39 @@ def _clixml_to_plain(text: str) -> str:
return without 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: def _winrm_failure_detail(stdout: str, stderr: str, exit_code: int) -> str:
stdout_plain = _clixml_to_plain(stdout) stdout_plain = _clixml_to_plain(stdout)
stderr_plain = _clixml_to_plain(stderr) stderr_plain = _clixml_to_plain(stderr)
@@ -213,8 +246,8 @@ def run_winrm_ps(
except Exception as exc: except Exception as exc:
return WinRmCmdResult(ok=False, message=f"WinRM error ({target}): {exc}", target=target) return WinRmCmdResult(ok=False, message=f"WinRM error ({target}): {exc}", target=target)
stdout = (result.std_out or b"").decode("utf-8", errors="replace") stdout = _decode_winrm_bytes(result.std_out or b"")
stderr = (result.std_err or b"").decode("utf-8", errors="replace") stderr = _decode_winrm_bytes(result.std_err or b"")
exit_code = int(result.status_code) exit_code = int(result.status_code)
stdout, stderr = _sanitize_winrm_streams(stdout, stderr) stdout, stderr = _sanitize_winrm_streams(stdout, stderr)
return _finalize_winrm_run( return _finalize_winrm_run(
@@ -260,8 +293,8 @@ def run_winrm_cmd(
except Exception as exc: except Exception as exc:
return WinRmCmdResult(ok=False, message=f"WinRM error ({target}): {exc}", target=target) return WinRmCmdResult(ok=False, message=f"WinRM error ({target}): {exc}", target=target)
stdout = (result.std_out or b"").decode("utf-8", errors="replace") stdout = _decode_winrm_bytes(result.std_out or b"")
stderr = (result.std_err or b"").decode("utf-8", errors="replace") stderr = _decode_winrm_bytes(result.std_err or b"")
exit_code = int(result.status_code) exit_code = int(result.status_code)
stdout, stderr = _sanitize_winrm_streams(stdout, stderr) stdout, stderr = _sanitize_winrm_streams(stdout, stderr)
return _finalize_winrm_run( return _finalize_winrm_run(
@@ -292,6 +325,9 @@ def _powershell_script_prelude() -> str:
return ( return (
"$ProgressPreference = 'SilentlyContinue'\n" "$ProgressPreference = 'SilentlyContinue'\n"
"$InformationPreference = '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" f"$staging = '{literal}'\n"
"$deploy = Join-Path $staging 'Deploy-LoginMonitor.ps1'\n" "$deploy = Join-Path $staging 'Deploy-LoginMonitor.ps1'\n"
"if (-not (Test-Path -LiteralPath $deploy)) { throw \"Deploy-LoginMonitor.ps1 missing in staging\" }\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" "Write-Output \"Running: $deploy -SourceShareRoot $staging\"\n"
"powershell.exe -NoProfile -ExecutionPolicy Bypass -File $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"
) )
+1 -1
View File
@@ -1,5 +1,5 @@
"""Единый источник версии SAC (API, health, логи, OpenAPI).""" """Единый источник версии SAC (API, health, логи, OpenAPI)."""
APP_NAME = "Security Alert Center" APP_NAME = "Security Alert Center"
APP_VERSION = "0.20.16" APP_VERSION = "0.20.17"
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}" APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
+20
View File
@@ -15,6 +15,7 @@ from app.services.winrm_connect import (
WinRmCmdResult, WinRmCmdResult,
_clixml_to_plain, _clixml_to_plain,
_custom_deploy_body, _custom_deploy_body,
_decode_winrm_bytes,
_deploy_from_staging_body, _deploy_from_staging_body,
_download_bundle_body, _download_bundle_body,
_prepare_staging_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(): def test_prepare_staging_recreates_remote_dir():
script = _prepare_staging_body(RDP_REMOTE_STAGING) script = _prepare_staging_body(RDP_REMOTE_STAGING)
assert "Remove-Item" in script assert "Remove-Item" in script
+1 -1
View File
@@ -1,4 +1,4 @@
/** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */ /** Fallback до загрузки /health; при релизе держите в sync с backend/app/version.py */
export const APP_NAME = "Security Alert Center"; 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}`; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;