fix: deliver RDP bundle via SAC HTTP zip download, not WinRM chunks (0.20.15)
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import Response
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -59,3 +60,17 @@ def post_agent_command_result(
|
|||||||
raise HTTPException(status_code=404, detail="Command not found")
|
raise HTTPException(status_code=404, detail="Command not found")
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"status": cmd.status, "command_uuid": cmd.command_uuid}
|
return {"status": cmd.status, "command_uuid": cmd.command_uuid}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/rdp-bundle/{token}")
|
||||||
|
def download_rdp_bundle(token: str) -> Response:
|
||||||
|
from app.services.rdp_bundle_delivery import get_rdp_bundle_zip
|
||||||
|
|
||||||
|
data = get_rdp_bundle_zip(token)
|
||||||
|
if not data:
|
||||||
|
raise HTTPException(status_code=404, detail="Bundle not found or expired")
|
||||||
|
return Response(
|
||||||
|
content=data,
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="sac-rdp-bundle.zip"'},
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""Short-lived in-memory RDP agent bundles for WinRM clients to download."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import io
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
TTL_SECONDS = 600
|
||||||
|
|
||||||
|
_lock = Lock()
|
||||||
|
_entries: dict[str, tuple[bytes, float]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _prune_expired(now: float) -> None:
|
||||||
|
expired = [token for token, (_, expires_at) in _entries.items() if expires_at <= now]
|
||||||
|
for token in expired:
|
||||||
|
_entries.pop(token, None)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def register_rdp_bundle_zip(zip_bytes: bytes) -> str:
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
expires_at = time.time() + TTL_SECONDS
|
||||||
|
with _lock:
|
||||||
|
_prune_expired(time.time())
|
||||||
|
_entries[token] = (zip_bytes, expires_at)
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def get_rdp_bundle_zip(token: str) -> bytes | None:
|
||||||
|
text = (token or "").strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
now = time.time()
|
||||||
|
with _lock:
|
||||||
|
_prune_expired(now)
|
||||||
|
entry = _entries.get(text)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
data, expires_at = entry
|
||||||
|
if expires_at <= now:
|
||||||
|
_entries.pop(text, None)
|
||||||
|
return None
|
||||||
|
return data
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
|
||||||
import re
|
import re
|
||||||
import socket
|
import socket
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -11,6 +10,7 @@ from pathlib import Path
|
|||||||
from app.config import get_settings
|
from app.config import get_settings
|
||||||
from app.models import Host
|
from app.models import Host
|
||||||
from app.services.agent_git_release import _cache_dir, clone_or_update_repo
|
from app.services.agent_git_release import _cache_dir, clone_or_update_repo
|
||||||
|
from app.services.rdp_bundle_delivery import build_rdp_bundle_zip, register_rdp_bundle_zip
|
||||||
|
|
||||||
_CLIXML_PROGRESS_NOISE = frozenset(
|
_CLIXML_PROGRESS_NOISE = frozenset(
|
||||||
{
|
{
|
||||||
@@ -38,7 +38,6 @@ RDP_BUNDLE_REQUIRED = frozenset(
|
|||||||
"Deploy-LoginMonitor.ps1",
|
"Deploy-LoginMonitor.ps1",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
_WINRM_FILE_CHUNK_BYTES = 20_000
|
|
||||||
|
|
||||||
|
|
||||||
class WinAdminNotConfiguredError(Exception):
|
class WinAdminNotConfiguredError(Exception):
|
||||||
@@ -344,64 +343,27 @@ def _deploy_from_staging_body(staging_path: str) -> str:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _write_file_chunk_body(remote_path: str, b64_chunk: str, *, append: bool) -> str:
|
def _bundle_download_url(token: str) -> str:
|
||||||
literal = _powershell_literal_path(remote_path)
|
base = get_settings().sac_public_url.rstrip("/")
|
||||||
if append:
|
return f"{base}/api/v1/agent/rdp-bundle/{token}"
|
||||||
return (
|
|
||||||
f"$path = '{literal}'\n"
|
|
||||||
f"$bytes = [Convert]::FromBase64String('{b64_chunk}')\n"
|
def _download_bundle_body(staging_path: str, bundle_url: str) -> str:
|
||||||
"$stream = [System.IO.File]::Open($path, [System.IO.FileMode]::Append)\n"
|
staging = _powershell_literal_path(staging_path)
|
||||||
"try { $stream.Write($bytes, 0, $bytes.Length) } finally { $stream.Close() }\n"
|
url = _powershell_literal_path(bundle_url)
|
||||||
)
|
|
||||||
return (
|
return (
|
||||||
f"$path = '{literal}'\n"
|
f"$staging = '{staging}'\n"
|
||||||
f"$bytes = [Convert]::FromBase64String('{b64_chunk}')\n"
|
f"$url = '{url}'\n"
|
||||||
"$dir = Split-Path -LiteralPath $path -Parent\n"
|
"$zip = Join-Path $staging 'sac-rdp-bundle.zip'\n"
|
||||||
"if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }\n"
|
"[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12\n"
|
||||||
"[System.IO.File]::WriteAllBytes($path, $bytes)\n"
|
"Write-Output \"Downloading bundle: $url\"\n"
|
||||||
|
"Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing\n"
|
||||||
|
"Expand-Archive -LiteralPath $zip -DestinationPath $staging -Force\n"
|
||||||
|
"Remove-Item -LiteralPath $zip -Force\n"
|
||||||
|
"Write-Output \"Bundle extracted to $staging\"\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _winrm_push_file(
|
|
||||||
*,
|
|
||||||
target: str,
|
|
||||||
user: str,
|
|
||||||
password: str,
|
|
||||||
remote_path: str,
|
|
||||||
data: bytes,
|
|
||||||
) -> WinRmCmdResult:
|
|
||||||
last: WinRmCmdResult | None = None
|
|
||||||
if not data:
|
|
||||||
body = (
|
|
||||||
f"$path = '{_powershell_literal_path(remote_path)}'\n"
|
|
||||||
"$dir = Split-Path -LiteralPath $path -Parent\n"
|
|
||||||
"if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }\n"
|
|
||||||
"[System.IO.File]::WriteAllBytes($path, [byte[]]@())\n"
|
|
||||||
)
|
|
||||||
return run_winrm_ps(
|
|
||||||
target=target,
|
|
||||||
user=user,
|
|
||||||
password=password,
|
|
||||||
script=_wrap_powershell_body(body),
|
|
||||||
timeout_sec=120,
|
|
||||||
)
|
|
||||||
for offset in range(0, len(data), _WINRM_FILE_CHUNK_BYTES):
|
|
||||||
chunk = data[offset : offset + _WINRM_FILE_CHUNK_BYTES]
|
|
||||||
b64_chunk = base64.b64encode(chunk).decode("ascii")
|
|
||||||
body = _write_file_chunk_body(remote_path, b64_chunk, append=offset > 0)
|
|
||||||
last = run_winrm_ps(
|
|
||||||
target=target,
|
|
||||||
user=user,
|
|
||||||
password=password,
|
|
||||||
script=_wrap_powershell_body(body),
|
|
||||||
timeout_sec=120,
|
|
||||||
)
|
|
||||||
if not last.ok:
|
|
||||||
return last
|
|
||||||
assert last is not None
|
|
||||||
return last
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_rdp_bundle_dir(repo_url: str, git_branch: str) -> Path:
|
def _fetch_rdp_bundle_dir(repo_url: str, git_branch: str) -> Path:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
return clone_or_update_repo(
|
return clone_or_update_repo(
|
||||||
@@ -460,6 +422,16 @@ def run_winrm_rdp_monitor_update(
|
|||||||
)
|
)
|
||||||
|
|
||||||
staging = RDP_REMOTE_STAGING
|
staging = RDP_REMOTE_STAGING
|
||||||
|
zip_bytes = build_rdp_bundle_zip(repo_dir, RDP_BUNDLE_FILES)
|
||||||
|
if not zip_bytes:
|
||||||
|
return WinRmCmdResult(
|
||||||
|
ok=False,
|
||||||
|
message="RDP bundle zip is empty on SAC",
|
||||||
|
target=target,
|
||||||
|
)
|
||||||
|
bundle_token = register_rdp_bundle_zip(zip_bytes)
|
||||||
|
bundle_url = _bundle_download_url(bundle_token)
|
||||||
|
|
||||||
prep = run_winrm_ps(
|
prep = run_winrm_ps(
|
||||||
target=target,
|
target=target,
|
||||||
user=user,
|
user=user,
|
||||||
@@ -470,31 +442,25 @@ def run_winrm_rdp_monitor_update(
|
|||||||
if not prep.ok:
|
if not prep.ok:
|
||||||
return prep
|
return prep
|
||||||
|
|
||||||
pushed: list[str] = []
|
download = run_winrm_ps(
|
||||||
log_parts = [prep.stdout]
|
target=target,
|
||||||
for filename in RDP_BUNDLE_FILES:
|
user=user,
|
||||||
local_file = repo_dir / filename
|
password=password,
|
||||||
if not local_file.is_file():
|
script=_wrap_powershell_body(_download_bundle_body(staging, bundle_url)),
|
||||||
continue
|
timeout_sec=300,
|
||||||
remote_file = f"{staging}\\{filename}"
|
)
|
||||||
push_result = _winrm_push_file(
|
if not download.ok:
|
||||||
|
return WinRmCmdResult(
|
||||||
|
ok=False,
|
||||||
|
message=(
|
||||||
|
f"Failed to download RDP bundle on client: {download.message} "
|
||||||
|
f"(URL: {bundle_url})"
|
||||||
|
),
|
||||||
target=target,
|
target=target,
|
||||||
user=user,
|
stdout="\n\n".join(part.strip() for part in [prep.stdout, download.stdout] if part.strip()),
|
||||||
password=password,
|
stderr=download.stderr,
|
||||||
remote_path=remote_file,
|
exit_code=download.exit_code,
|
||||||
data=local_file.read_bytes(),
|
|
||||||
)
|
)
|
||||||
log_parts.append(push_result.stdout)
|
|
||||||
if not push_result.ok:
|
|
||||||
return WinRmCmdResult(
|
|
||||||
ok=False,
|
|
||||||
message=f"Failed to push {filename} to client: {push_result.message}",
|
|
||||||
target=target,
|
|
||||||
stdout="\n".join(part.strip() for part in log_parts if part.strip()),
|
|
||||||
stderr=push_result.stderr,
|
|
||||||
exit_code=push_result.exit_code,
|
|
||||||
)
|
|
||||||
pushed.append(filename)
|
|
||||||
|
|
||||||
deploy = run_winrm_ps(
|
deploy = run_winrm_ps(
|
||||||
target=target,
|
target=target,
|
||||||
@@ -503,10 +469,10 @@ def run_winrm_rdp_monitor_update(
|
|||||||
script=_wrap_powershell_body(_deploy_from_staging_body(staging)),
|
script=_wrap_powershell_body(_deploy_from_staging_body(staging)),
|
||||||
timeout_sec=900,
|
timeout_sec=900,
|
||||||
)
|
)
|
||||||
header = f"SAC pushed {len(pushed)} file(s) from git to {staging}"
|
header = f"SAC served bundle from git; client downloaded to {staging}"
|
||||||
stdout = "\n\n".join(
|
stdout = "\n\n".join(
|
||||||
part.strip()
|
part.strip()
|
||||||
for part in [header, "\n".join(part.strip() for part in log_parts if part.strip()), deploy.stdout]
|
for part in [header, prep.stdout, download.stdout, deploy.stdout]
|
||||||
if part.strip()
|
if part.strip()
|
||||||
)
|
)
|
||||||
return WinRmCmdResult(
|
return WinRmCmdResult(
|
||||||
|
|||||||
@@ -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.14"
|
APP_VERSION = "0.20.15"
|
||||||
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
APP_VERSION_LABEL = f"{APP_NAME} v.{APP_VERSION}"
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""WinRM command builder, bundle push and CLIXML error parsing tests."""
|
"""WinRM bundle delivery and CLIXML error parsing tests."""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
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.winrm_connect import (
|
from app.services.winrm_connect import (
|
||||||
RDP_BUNDLE_REQUIRED,
|
RDP_BUNDLE_REQUIRED,
|
||||||
RDP_REMOTE_STAGING,
|
RDP_REMOTE_STAGING,
|
||||||
@@ -10,9 +11,9 @@ from app.services.winrm_connect import (
|
|||||||
_clixml_to_plain,
|
_clixml_to_plain,
|
||||||
_custom_deploy_body,
|
_custom_deploy_body,
|
||||||
_deploy_from_staging_body,
|
_deploy_from_staging_body,
|
||||||
|
_download_bundle_body,
|
||||||
_prepare_staging_body,
|
_prepare_staging_body,
|
||||||
_winrm_failure_detail,
|
_winrm_failure_detail,
|
||||||
_write_file_chunk_body,
|
|
||||||
run_winrm_rdp_monitor_update,
|
run_winrm_rdp_monitor_update,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,36 +24,42 @@ def test_prepare_staging_recreates_remote_dir():
|
|||||||
assert "_sac_staging" in script
|
assert "_sac_staging" in script
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_bundle_uses_invoke_webrequest():
|
||||||
|
script = _download_bundle_body(
|
||||||
|
RDP_REMOTE_STAGING,
|
||||||
|
"https://sac.example/api/v1/agent/rdp-bundle/token",
|
||||||
|
)
|
||||||
|
assert "Invoke-WebRequest" in script
|
||||||
|
assert "Expand-Archive" in script
|
||||||
|
assert "WriteAllBytes" not in script
|
||||||
|
|
||||||
|
|
||||||
def test_deploy_from_staging_runs_local_bundle():
|
def test_deploy_from_staging_runs_local_bundle():
|
||||||
script = _deploy_from_staging_body(RDP_REMOTE_STAGING)
|
script = _deploy_from_staging_body(RDP_REMOTE_STAGING)
|
||||||
assert "-SourceShareRoot" in script
|
assert "-SourceShareRoot" in script
|
||||||
assert "Deploy-LoginMonitor.ps1" in script
|
assert "Deploy-LoginMonitor.ps1" in script
|
||||||
|
|
||||||
|
|
||||||
def test_write_file_chunk_body_supports_append():
|
|
||||||
first = _write_file_chunk_body(r"C:\temp\a.ps1", "YQ==", append=False)
|
|
||||||
second = _write_file_chunk_body(r"C:\temp\a.ps1", "Yg==", append=True)
|
|
||||||
assert "WriteAllBytes" in first
|
|
||||||
assert "Append" in second
|
|
||||||
|
|
||||||
|
|
||||||
def test_custom_deploy_script_uses_literal_path():
|
|
||||||
script = _custom_deploy_body(r"C:\tmp\Deploy-LoginMonitor.ps1")
|
|
||||||
assert r"C:\tmp\Deploy-LoginMonitor.ps1" in script
|
|
||||||
assert "Test-Path -LiteralPath" in script
|
|
||||||
|
|
||||||
|
|
||||||
def test_custom_deploy_script_escapes_single_quotes():
|
def test_custom_deploy_script_escapes_single_quotes():
|
||||||
script = _custom_deploy_body(r"C:\Users\O'Brien\Deploy-LoginMonitor.ps1")
|
script = _custom_deploy_body(r"C:\Users\O'Brien\Deploy-LoginMonitor.ps1")
|
||||||
assert "O''Brien" in script
|
assert "O''Brien" in script
|
||||||
|
|
||||||
|
|
||||||
def test_run_winrm_rdp_monitor_update_pushes_bundle_from_sac_git(tmp_path: Path):
|
def test_rdp_bundle_zip_roundtrip(tmp_path: Path):
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
repo.mkdir()
|
||||||
|
for name in RDP_BUNDLE_REQUIRED:
|
||||||
|
(repo / name).write_text(name, encoding="utf-8")
|
||||||
|
zip_bytes = build_rdp_bundle_zip(repo, tuple(RDP_BUNDLE_REQUIRED))
|
||||||
|
token = register_rdp_bundle_zip(zip_bytes)
|
||||||
|
assert get_rdp_bundle_zip(token) == zip_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_winrm_rdp_monitor_update_downloads_bundle_from_sac(tmp_path: Path):
|
||||||
repo = tmp_path / "repo"
|
repo = tmp_path / "repo"
|
||||||
repo.mkdir()
|
repo.mkdir()
|
||||||
for name in RDP_BUNDLE_REQUIRED:
|
for name in RDP_BUNDLE_REQUIRED:
|
||||||
(repo / name).write_text(f"content-{name}", encoding="utf-8")
|
(repo / name).write_text(f"content-{name}", encoding="utf-8")
|
||||||
(repo / "Sac-Client.ps1").write_text("Sac client", encoding="utf-8")
|
|
||||||
|
|
||||||
calls: list[str] = []
|
calls: list[str] = []
|
||||||
|
|
||||||
@@ -60,7 +67,9 @@ def test_run_winrm_rdp_monitor_update_pushes_bundle_from_sac_git(tmp_path: Path)
|
|||||||
calls.append(script)
|
calls.append(script)
|
||||||
if "Remove-Item" in script:
|
if "Remove-Item" in script:
|
||||||
return WinRmCmdResult(ok=True, message="staging ok", target="pc", stdout="Staging ready")
|
return WinRmCmdResult(ok=True, message="staging ok", target="pc", stdout="Staging ready")
|
||||||
if "Deploy-LoginMonitor.ps1 missing" in script or "& $deploy" in script:
|
if "Invoke-WebRequest" in script:
|
||||||
|
return WinRmCmdResult(ok=True, message="download ok", target="pc", stdout="Bundle extracted")
|
||||||
|
if "& $deploy" in script:
|
||||||
return WinRmCmdResult(
|
return WinRmCmdResult(
|
||||||
ok=True,
|
ok=True,
|
||||||
message="WinRM OK (pc), exit 0",
|
message="WinRM OK (pc), exit 0",
|
||||||
@@ -68,10 +77,11 @@ def test_run_winrm_rdp_monitor_update_pushes_bundle_from_sac_git(tmp_path: Path)
|
|||||||
stdout="deployed 1.2.3",
|
stdout="deployed 1.2.3",
|
||||||
exit_code=0,
|
exit_code=0,
|
||||||
)
|
)
|
||||||
return WinRmCmdResult(ok=True, message="chunk ok", target="pc", exit_code=0)
|
return WinRmCmdResult(ok=True, message="ok", target="pc", exit_code=0)
|
||||||
|
|
||||||
with (
|
with (
|
||||||
patch("app.services.winrm_connect._fetch_rdp_bundle_dir", return_value=repo),
|
patch("app.services.winrm_connect._fetch_rdp_bundle_dir", return_value=repo),
|
||||||
|
patch("app.services.winrm_connect._bundle_download_url", return_value="https://sac.test/bundle.zip"),
|
||||||
patch("app.services.winrm_connect.run_winrm_ps", side_effect=fake_run_ps),
|
patch("app.services.winrm_connect.run_winrm_ps", side_effect=fake_run_ps),
|
||||||
):
|
):
|
||||||
result = run_winrm_rdp_monitor_update(
|
result = run_winrm_rdp_monitor_update(
|
||||||
@@ -82,24 +92,9 @@ def test_run_winrm_rdp_monitor_update_pushes_bundle_from_sac_git(tmp_path: Path)
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result.ok is True
|
assert result.ok is True
|
||||||
assert "SAC pushed" in result.stdout
|
assert "SAC served bundle" in result.stdout
|
||||||
assert any("WriteAllBytes" in call or "Append" in call for call in calls)
|
assert any("Invoke-WebRequest" in call for call in calls)
|
||||||
assert "git.exe" not in "\n".join(calls)
|
assert not any("FromBase64String" in call for call in calls)
|
||||||
|
|
||||||
|
|
||||||
def test_winrm_failure_detail_extracts_message_from_clixml():
|
|
||||||
clixml = (
|
|
||||||
"#< CLIXML\n"
|
|
||||||
'<Objs><Obj><MS><S N="Message">Preparing modules for first use.</S>'
|
|
||||||
'<S N="Message">Deploy-LoginMonitor.ps1 not found on client PC</S></MS></Obj></Objs>'
|
|
||||||
)
|
|
||||||
detail = _winrm_failure_detail("", clixml, 1)
|
|
||||||
assert detail == "Deploy-LoginMonitor.ps1 not found on client PC"
|
|
||||||
|
|
||||||
|
|
||||||
def test_winrm_failure_detail_prefers_plain_stderr():
|
|
||||||
detail = _winrm_failure_detail("", "Access is denied", 5)
|
|
||||||
assert detail == "Access is denied"
|
|
||||||
|
|
||||||
|
|
||||||
def test_winrm_failure_detail_never_returns_raw_clixml_progress():
|
def test_winrm_failure_detail_never_returns_raw_clixml_progress():
|
||||||
@@ -109,7 +104,6 @@ def test_winrm_failure_detail_never_returns_raw_clixml_progress():
|
|||||||
)
|
)
|
||||||
detail = _winrm_failure_detail("", clixml, 1)
|
detail = _winrm_failure_detail("", clixml, 1)
|
||||||
assert "#< CLIXML" not in detail
|
assert "#< CLIXML" not in detail
|
||||||
assert "exit code" in detail.lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_clixml_to_plain_strips_progress_only_blob():
|
def test_clixml_to_plain_strips_progress_only_blob():
|
||||||
|
|||||||
@@ -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.14";
|
export const APP_VERSION = "0.20.15";
|
||||||
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
export const APP_VERSION_LABEL = `${APP_NAME} v.${APP_VERSION}`;
|
||||||
|
|||||||
Reference in New Issue
Block a user