diff --git a/backend/app/services/rdp_bundle_delivery.py b/backend/app/services/rdp_bundle_delivery.py index 90c12fd..f45ce8c 100644 --- a/backend/app/services/rdp_bundle_delivery.py +++ b/backend/app/services/rdp_bundle_delivery.py @@ -10,6 +10,8 @@ from pathlib import Path from threading import Lock TTL_SECONDS = 600 +_UTF8_BOM = b"\xef\xbb\xbf" +_BOM_SUFFIXES = {".ps1", ".txt"} _lock = Lock() _entries: dict[str, tuple[bytes, float]] = {} @@ -21,13 +23,23 @@ def _prune_expired(now: float) -> None: _entries.pop(token, None) +def _bundle_file_bytes(path: Path) -> bytes: + data = path.read_bytes() + if path.suffix.lower() in _BOM_SUFFIXES and not data.startswith(_UTF8_BOM): + return _UTF8_BOM + data + return data + + def build_rdp_bundle_zip(repo_dir: Path, filenames: tuple[str, ...]) -> bytes: buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive: for name in filenames: path = repo_dir / name if path.is_file(): - archive.write(path, name) + info = zipfile.ZipInfo(name) + info.compress_type = zipfile.ZIP_DEFLATED + info.flag_bits |= 0x800 + archive.writestr(info, _bundle_file_bytes(path)) return buffer.getvalue() diff --git a/backend/app/services/winrm_connect.py b/backend/app/services/winrm_connect.py index 8259e0b..07dd039 100644 --- a/backend/app/services/winrm_connect.py +++ b/backend/app/services/winrm_connect.py @@ -339,7 +339,7 @@ def _deploy_from_staging_body(staging_path: str) -> str: "$deploy = Join-Path $staging 'Deploy-LoginMonitor.ps1'\n" "if (-not (Test-Path -LiteralPath $deploy)) { throw \"Deploy-LoginMonitor.ps1 missing in staging\" }\n" "Write-Output \"Running: $deploy -SourceShareRoot $staging\"\n" - "& $deploy -SourceShareRoot $staging\n" + "powershell.exe -NoProfile -ExecutionPolicy Bypass -File $deploy -SourceShareRoot $staging\n" ) @@ -358,7 +358,8 @@ def _download_bundle_body(staging_path: str, bundle_url: str) -> str: "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12\n" "Write-Output \"Downloading bundle: $url\"\n" "Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing\n" - "Expand-Archive -LiteralPath $zip -DestinationPath $staging -Force\n" + "Add-Type -AssemblyName System.IO.Compression.FileSystem\n" + "[System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $staging)\n" "Remove-Item -LiteralPath $zip -Force\n" "Write-Output \"Bundle extracted to $staging\"\n" ) diff --git a/backend/app/version.py b/backend/app/version.py index 0fbdec4..2417d73 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.15" +APP_VERSION = "0.20.16" 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 8dce42e..cbf484e 100644 --- a/backend/tests/test_winrm_connect.py +++ b/backend/tests/test_winrm_connect.py @@ -3,7 +3,12 @@ from pathlib import Path from unittest.mock import patch -from app.services.rdp_bundle_delivery import build_rdp_bundle_zip, get_rdp_bundle_zip, register_rdp_bundle_zip +from app.services.rdp_bundle_delivery import ( + _UTF8_BOM, + build_rdp_bundle_zip, + get_rdp_bundle_zip, + register_rdp_bundle_zip, +) from app.services.winrm_connect import ( RDP_BUNDLE_REQUIRED, RDP_REMOTE_STAGING, @@ -30,8 +35,8 @@ def test_download_bundle_uses_invoke_webrequest(): "https://sac.example/api/v1/agent/rdp-bundle/token", ) assert "Invoke-WebRequest" in script - assert "Expand-Archive" in script - assert "WriteAllBytes" not in script + assert "ZipFile]::ExtractToDirectory" in script + assert "Expand-Archive" not in script def test_deploy_from_staging_runs_local_bundle(): @@ -45,6 +50,19 @@ def test_custom_deploy_script_escapes_single_quotes(): assert "O''Brien" in script +def test_rdp_bundle_zip_adds_utf8_bom_for_ps1(tmp_path: Path): + repo = tmp_path / "repo" + repo.mkdir() + (repo / "Deploy-LoginMonitor.ps1").write_text("# test", encoding="utf-8") + zip_bytes = build_rdp_bundle_zip(repo, ("Deploy-LoginMonitor.ps1",)) + import zipfile + import io + + with zipfile.ZipFile(io.BytesIO(zip_bytes)) as archive: + data = archive.read("Deploy-LoginMonitor.ps1") + assert data.startswith(_UTF8_BOM) + + def test_rdp_bundle_zip_roundtrip(tmp_path: Path): repo = tmp_path / "repo" repo.mkdir() @@ -69,7 +87,7 @@ def test_run_winrm_rdp_monitor_update_downloads_bundle_from_sac(tmp_path: Path): return WinRmCmdResult(ok=True, message="staging ok", target="pc", stdout="Staging ready") if "Invoke-WebRequest" in script: return WinRmCmdResult(ok=True, message="download ok", target="pc", stdout="Bundle extracted") - if "& $deploy" in script: + if "powershell.exe -NoProfile" in script or "& $deploy" in script: return WinRmCmdResult( ok=True, message="WinRM OK (pc), exit 0", diff --git a/frontend/src/version.ts b/frontend/src/version.ts index ac63016..6eed04c 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.15"; +export const APP_VERSION = "0.20.16"; export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;