Persist assemblies as base64 and decode for execution

This commit is contained in:
2025-10-03 21:16:43 -06:00
parent 304c1e9728
commit 211e37c64c
7 changed files with 370 additions and 21 deletions

View File

@@ -7,6 +7,7 @@ import time
import json
import socket
import subprocess
import base64
from typing import Optional
try:
@@ -39,6 +40,39 @@ def _project_root():
return os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
def _decode_base64_text(value):
if not isinstance(value, str):
return None
stripped = value.strip()
if not stripped:
return ""
cleaned = ''.join(stripped.split())
if not cleaned:
return ""
try:
decoded = base64.b64decode(cleaned, validate=True)
except Exception:
return None
try:
return decoded.decode('utf-8')
except Exception:
return decoded.decode('utf-8', errors='replace')
def _decode_playbook_content(raw_content, encoding_hint):
if isinstance(raw_content, str):
encoding = str(encoding_hint or '').strip().lower()
if encoding in ('base64', 'b64', 'base-64'):
decoded = _decode_base64_text(raw_content)
if decoded is not None:
return decoded
decoded = _decode_base64_text(raw_content)
if decoded is not None:
return decoded
return raw_content
return ''
def _agent_root():
# Resolve Agent root at runtime.
# Typical runtime: <ProjectRoot>/Agent/Borealis/Roles/<this_file>
@@ -801,7 +835,7 @@ try {{
return
# Accept provided run_id or generate one
run_id = (payload.get('run_id') or '').strip() or uuid.uuid4().hex
content = payload.get('playbook_content') or ''
content = _decode_playbook_content(payload.get('playbook_content'), payload.get('playbook_encoding'))
p_name = payload.get('playbook_name') or ''
act_id = payload.get('activity_job_id')
sched_job_id = payload.get('scheduled_job_id')
@@ -874,7 +908,7 @@ try {{
if target and target != hostname.lower():
return
run_id = uuid.uuid4().hex
content = payload.get('script_content') or ''
content = _decode_playbook_content(payload.get('script_content'), payload.get('script_encoding'))
p_name = payload.get('script_name') or ''
self._runs[run_id] = {'cancel': False, 'proc': None}
asyncio.create_task(self._run_playbook(run_id, content, playbook_name=p_name, activity_job_id=payload.get('job_id'), connection='local'))

View File

@@ -4,7 +4,8 @@ import re
import asyncio
import tempfile
import uuid
from typing import Dict, List
import base64
from typing import Dict, List, Optional
from PyQt5 import QtWidgets, QtGui
@@ -65,6 +66,40 @@ def _apply_variable_aliases(env_map: Dict[str, str], variables: List[Dict[str, s
return env_map
def _decode_base64_text(value: str) -> Optional[str]:
if not isinstance(value, str):
return None
stripped = value.strip()
if not stripped:
return ""
try:
cleaned = re.sub(r"\s+", "", stripped)
except Exception:
cleaned = stripped
try:
decoded = base64.b64decode(cleaned, validate=True)
except Exception:
return None
try:
return decoded.decode("utf-8")
except Exception:
return decoded.decode("utf-8", errors="replace")
def _decode_script_content(raw_content, encoding_hint) -> str:
if isinstance(raw_content, str):
encoding = str(encoding_hint or "").strip().lower()
if encoding in ("base64", "b64", "base-64"):
decoded = _decode_base64_text(raw_content)
if decoded is not None:
return decoded
decoded = _decode_base64_text(raw_content)
if decoded is not None:
return decoded
return raw_content
return ""
def _ps_literal(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
@@ -242,7 +277,7 @@ class Role:
job_id = payload.get('job_id')
script_type = (payload.get('script_type') or '').lower()
run_mode = (payload.get('run_mode') or 'current_user').lower()
content = payload.get('script_content') or ''
content = _decode_script_content(payload.get('script_content'), payload.get('script_encoding'))
raw_env = payload.get('environment')
env_map = _sanitize_env_map(raw_env)
variables = payload.get('variables') if isinstance(payload.get('variables'), list) else []

View File

@@ -5,7 +5,8 @@ import tempfile
import uuid
import time
import subprocess
from typing import Dict, List
import base64
from typing import Dict, List, Optional
ROLE_NAME = 'script_exec_system'
@@ -67,6 +68,40 @@ def _apply_variable_aliases(env_map: Dict[str, str], variables: List[Dict[str, s
return env_map
def _decode_base64_text(value: str) -> Optional[str]:
if not isinstance(value, str):
return None
stripped = value.strip()
if not stripped:
return ""
try:
cleaned = re.sub(r"\s+", "", stripped)
except Exception:
cleaned = stripped
try:
decoded = base64.b64decode(cleaned, validate=True)
except Exception:
return None
try:
return decoded.decode("utf-8")
except Exception:
return decoded.decode("utf-8", errors="replace")
def _decode_script_content(raw_content, encoding_hint) -> str:
if isinstance(raw_content, str):
encoding = str(encoding_hint or "").strip().lower()
if encoding in ("base64", "b64", "base-64"):
decoded = _decode_base64_text(raw_content)
if decoded is not None:
return decoded
decoded = _decode_base64_text(raw_content)
if decoded is not None:
return decoded
return raw_content
return ""
def _ps_literal(value: str) -> str:
return "'" + value.replace("'", "''") + "'"
@@ -236,7 +271,7 @@ class Role:
return
job_id = payload.get('job_id')
script_type = (payload.get('script_type') or '').lower()
content = payload.get('script_content') or ''
content = _decode_script_content(payload.get('script_content'), payload.get('script_encoding'))
raw_env = payload.get('environment')
env_map = _sanitize_env_map(raw_env)
variables = payload.get('variables') if isinstance(payload.get('variables'), list) else []

View File

@@ -156,6 +156,39 @@ def _log_agent(message: str, fname: str = 'agent.log'):
except Exception:
pass
def _decode_base64_text(value):
if not isinstance(value, str):
return None
stripped = value.strip()
if not stripped:
return ""
cleaned = ''.join(stripped.split())
if not cleaned:
return ""
try:
decoded = base64.b64decode(cleaned, validate=True)
except Exception:
return None
try:
return decoded.decode('utf-8')
except Exception:
return decoded.decode('utf-8', errors='replace')
def _decode_script_payload(content, encoding_hint):
if isinstance(content, str):
encoding = str(encoding_hint or '').strip().lower()
if encoding in ('base64', 'b64', 'base-64'):
decoded = _decode_base64_text(content)
if decoded is not None:
return decoded
decoded = _decode_base64_text(content)
if decoded is not None:
return decoded
return content
return ''
def _resolve_config_path():
"""
Resolve the path for agent settings json in the centralized location:
@@ -1520,7 +1553,7 @@ if __name__=='__main__':
return
job_id = payload.get('job_id')
script_type = (payload.get('script_type') or '').lower()
content = payload.get('script_content') or ''
content = _decode_script_payload(payload.get('script_content'), payload.get('script_encoding'))
run_mode = (payload.get('run_mode') or 'current_user').lower()
if script_type != 'powershell':
await sio.emit('quick_job_result', { 'job_id': job_id, 'status': 'Failed', 'stdout': '', 'stderr': f"Unsupported type: {script_type}" })

View File

@@ -198,6 +198,61 @@ function normalizeVariablesFromServer(vars = []) {
}));
}
function decodeBase64String(data = "") {
if (typeof data !== "string") {
return { success: false, value: "" };
}
if (!data.trim()) {
return { success: true, value: "" };
}
try {
if (typeof window !== "undefined" && typeof window.atob === "function") {
const binary = window.atob(data);
if (typeof TextDecoder !== "undefined") {
const decoder = new TextDecoder("utf-8", { fatal: false });
return { success: true, value: decoder.decode(Uint8Array.from(binary, (c) => c.charCodeAt(0))) };
}
return { success: true, value: binary };
}
} catch (err) {
// fall through to Buffer fallback
}
try {
if (typeof Buffer !== "undefined") {
return { success: true, value: Buffer.from(data, "base64").toString("utf-8") };
}
} catch (err) {
// ignore
}
return { success: false, value: "" };
}
function encodeBase64String(text = "") {
if (typeof text !== "string") {
text = text == null ? "" : String(text);
}
if (!text) return "";
try {
if (typeof TextEncoder !== "undefined" && typeof window !== "undefined" && typeof window.btoa === "function") {
const encoder = new TextEncoder();
const bytes = encoder.encode(text);
let binary = "";
bytes.forEach((b) => { binary += String.fromCharCode(b); });
return window.btoa(binary);
}
} catch (err) {
// fall through to Buffer fallback
}
try {
if (typeof Buffer !== "undefined") {
return Buffer.from(text, "utf-8").toString("base64");
}
} catch (err) {
// ignore
}
return "";
}
function normalizeFilesFromServer(files = []) {
return (Array.isArray(files) ? files : []).map((f, idx) => ({
id: `${Date.now()}_${idx}_${Math.random().toString(36).slice(2, 8)}`,
@@ -219,7 +274,20 @@ function fromServerDocument(doc = {}, defaultType = "powershell") {
? doc.script_lines.map((line) => (line == null ? "" : String(line))).join("\n")
: "";
const script = doc.script ?? doc.content ?? legacyScript;
assembly.script = typeof script === "string" ? script : legacyScript;
if (typeof script === "string") {
const encoding = (doc.script_encoding || doc.scriptEncoding || "").toLowerCase();
if (["base64", "b64", "base-64"].includes(encoding)) {
const decoded = decodeBase64String(script);
assembly.script = decoded.success ? decoded.value : "";
} else if (!encoding) {
const decoded = decodeBase64String(script);
assembly.script = decoded.success ? decoded.value : script;
} else {
assembly.script = script;
}
} else {
assembly.script = legacyScript;
}
const timeout = doc.timeout_seconds ?? doc.timeout ?? assembly.timeoutSeconds;
assembly.timeoutSeconds = Number.isFinite(Number(timeout))
? Number(timeout)
@@ -241,13 +309,15 @@ function toServerDocument(assembly) {
: "";
const timeoutNumeric = Number(assembly.timeoutSeconds);
const timeoutSeconds = Number.isFinite(timeoutNumeric) ? Math.max(0, Math.round(timeoutNumeric)) : 3600;
const encodedScript = encodeBase64String(normalizedScript);
return {
version: 1,
name: assembly.name?.trim() || "",
description: assembly.description || "",
category: assembly.category || "script",
type: assembly.type || "powershell",
script: normalizedScript,
script: encodedScript,
script_encoding: "base64",
timeout_seconds: timeoutSeconds,
sites: {
mode: assembly.sites?.mode === "specific" ? "specific" : "all",

View File

@@ -2,6 +2,7 @@ import os
import time
import json
import os
import base64
import re
import sqlite3
from typing import Any, Dict, List, Optional, Tuple, Callable
@@ -33,6 +34,53 @@ def _env_string(value: Any) -> str:
return str(value)
def _decode_base64_text(value: Any) -> Optional[str]:
if not isinstance(value, str):
return None
stripped = value.strip()
if not stripped:
return ""
try:
cleaned = re.sub(r"\s+", "", stripped)
except Exception:
cleaned = stripped
try:
decoded = base64.b64decode(cleaned, validate=True)
except Exception:
return None
try:
return decoded.decode("utf-8")
except Exception:
return decoded.decode("utf-8", errors="replace")
def _decode_script_content(value: Any, encoding_hint: str = "") -> str:
encoding = (encoding_hint or "").strip().lower()
if isinstance(value, str):
if encoding in ("base64", "b64", "base-64"):
decoded = _decode_base64_text(value)
if decoded is not None:
return decoded.replace("\r\n", "\n")
decoded = _decode_base64_text(value)
if decoded is not None:
return decoded.replace("\r\n", "\n")
return value.replace("\r\n", "\n")
return ""
def _encode_script_content(script_text: Any) -> str:
if not isinstance(script_text, str):
if script_text is None:
script_text = ""
else:
script_text = str(script_text)
normalized = script_text.replace("\r\n", "\n")
if not normalized:
return ""
encoded = base64.b64encode(normalized.encode("utf-8"))
return encoded.decode("ascii")
def _canonical_env_key(name: Any) -> str:
try:
return re.sub(r"[^A-Za-z0-9_]", "_", str(name or "").strip()).upper()
@@ -338,6 +386,7 @@ class JobScheduler:
if typ in ("powershell", "batch", "bash", "ansible"):
doc["type"] = typ
script_val = data.get("script")
content_val = data.get("content")
script_lines = data.get("script_lines")
if isinstance(script_lines, list):
try:
@@ -347,11 +396,24 @@ class JobScheduler:
elif isinstance(script_val, str):
doc["script"] = script_val
else:
content_val = data.get("content")
if isinstance(content_val, str):
doc["script"] = content_val
normalized_script = (doc["script"] or "").replace("\r\n", "\n")
doc["script"] = normalized_script
encoding_hint = str(data.get("script_encoding") or data.get("scriptEncoding") or "").strip().lower()
doc["script"] = _decode_script_content(doc.get("script"), encoding_hint)
if encoding_hint in ("base64", "b64", "base-64"):
doc["script_encoding"] = "base64"
else:
probe_source = ""
if isinstance(script_val, str) and script_val:
probe_source = script_val
elif isinstance(content_val, str) and content_val:
probe_source = content_val
decoded_probe = _decode_base64_text(probe_source) if probe_source else None
if decoded_probe is not None:
doc["script_encoding"] = "base64"
doc["script"] = decoded_probe.replace("\r\n", "\n")
else:
doc["script_encoding"] = "plain"
try:
timeout_raw = data.get("timeout_seconds", data.get("timeout"))
if timeout_raw is None:
@@ -423,6 +485,7 @@ class JobScheduler:
return
doc = self._load_assembly_document(abs_path, "ansible")
content = doc.get("script") or ""
encoded_content = _encode_script_content(content)
variables = doc.get("variables") or []
files = doc.get("files") or []
@@ -457,7 +520,8 @@ class JobScheduler:
"run_id": uuid.uuid4().hex,
"target_hostname": str(hostname),
"playbook_name": os.path.basename(abs_path),
"playbook_content": content,
"playbook_content": encoded_content,
"playbook_encoding": "base64",
"activity_job_id": act_id,
"scheduled_job_id": int(scheduled_job_id),
"scheduled_run_id": int(scheduled_run_id),
@@ -517,6 +581,7 @@ class JobScheduler:
env_map, variables, literal_lookup = _prepare_variable_context(doc_variables, overrides)
content = _rewrite_powershell_script(content, literal_lookup)
encoded_content = _encode_script_content(content)
timeout_seconds = 0
try:
timeout_seconds = max(0, int(doc.get("timeout_seconds") or 0))
@@ -557,7 +622,8 @@ class JobScheduler:
"script_type": stype,
"script_name": os.path.basename(abs_path),
"script_path": path_norm,
"script_content": content,
"script_content": encoded_content,
"script_encoding": "base64",
"environment": env_map,
"variables": variables,
"timeout_seconds": timeout_seconds,

View File

@@ -689,6 +689,64 @@ def _empty_assembly_document(default_type: str = "powershell") -> Dict[str, Any]
}
def _decode_base64_text(value: Any) -> Optional[str]:
if not isinstance(value, str):
return None
stripped = value.strip()
if not stripped:
return ""
try:
cleaned = re.sub(r"\s+", "", stripped)
except Exception:
cleaned = stripped
try:
decoded = base64.b64decode(cleaned, validate=True)
except Exception:
return None
try:
return decoded.decode("utf-8")
except Exception:
return decoded.decode("utf-8", errors="replace")
def _decode_script_content(value: Any, encoding_hint: str = "") -> str:
encoding = (encoding_hint or "").strip().lower()
if isinstance(value, str):
if encoding in ("base64", "b64", "base-64"):
decoded = _decode_base64_text(value)
if decoded is not None:
return decoded.replace("\r\n", "\n")
decoded = _decode_base64_text(value)
if decoded is not None:
return decoded.replace("\r\n", "\n")
return value.replace("\r\n", "\n")
return ""
def _encode_script_content(script_text: Any) -> str:
if not isinstance(script_text, str):
if script_text is None:
script_text = ""
else:
script_text = str(script_text)
normalized = script_text.replace("\r\n", "\n")
if not normalized:
return ""
encoded = base64.b64encode(normalized.encode("utf-8"))
return encoded.decode("ascii")
def _prepare_assembly_storage(doc: Dict[str, Any]) -> Dict[str, Any]:
stored: Dict[str, Any] = {}
for key, value in (doc or {}).items():
if key == "script":
stored[key] = _encode_script_content(value)
else:
stored[key] = value
stored["script_encoding"] = "base64"
return stored
def _normalize_assembly_document(obj: Any, default_type: str, base_name: str) -> Dict[str, Any]:
doc = _empty_assembly_document(default_type)
if not isinstance(obj, dict):
@@ -703,6 +761,7 @@ def _normalize_assembly_document(obj: Any, default_type: str, base_name: str) ->
if typ in ("powershell", "batch", "bash", "ansible"):
doc["type"] = typ
script_val = obj.get("script")
content_val = obj.get("content")
script_lines = obj.get("script_lines")
if isinstance(script_lines, list):
try:
@@ -712,11 +771,24 @@ def _normalize_assembly_document(obj: Any, default_type: str, base_name: str) ->
elif isinstance(script_val, str):
doc["script"] = script_val
else:
content_val = obj.get("content")
if isinstance(content_val, str):
doc["script"] = content_val
normalized_script = (doc["script"] or "").replace("\r\n", "\n")
doc["script"] = normalized_script
encoding_hint = str(obj.get("script_encoding") or obj.get("scriptEncoding") or "").strip().lower()
doc["script"] = _decode_script_content(doc.get("script"), encoding_hint)
if encoding_hint in ("base64", "b64", "base-64"):
doc["script_encoding"] = "base64"
else:
probe_source = ""
if isinstance(script_val, str) and script_val:
probe_source = script_val
elif isinstance(content_val, str) and content_val:
probe_source = content_val
decoded_probe = _decode_base64_text(probe_source) if probe_source else None
if decoded_probe is not None:
doc["script_encoding"] = "base64"
doc["script"] = decoded_probe.replace("\r\n", "\n")
else:
doc["script_encoding"] = "plain"
timeout_val = obj.get("timeout_seconds", obj.get("timeout"))
if timeout_val is not None:
try:
@@ -853,7 +925,7 @@ def assembly_create():
base_name,
)
with open(abs_path, "w", encoding="utf-8") as fh:
json.dump(normalized, fh, indent=2)
json.dump(_prepare_assembly_storage(normalized), fh, indent=2)
rel_new = os.path.relpath(abs_path, root).replace(os.sep, "/")
return jsonify({"status": "ok", "rel_path": rel_new})
else:
@@ -902,7 +974,7 @@ def assembly_edit():
base_name,
)
with open(target_abs, "w", encoding="utf-8") as fh:
json.dump(normalized, fh, indent=2)
json.dump(_prepare_assembly_storage(normalized), fh, indent=2)
if target_abs != abs_path:
try:
os.remove(abs_path)
@@ -2993,6 +3065,7 @@ def scripts_quick_run():
env_map, variables, literal_lookup = _prepare_variable_context(doc_variables, overrides)
content = _rewrite_powershell_script(content, literal_lookup)
encoded_content = _encode_script_content(content)
timeout_seconds = 0
try:
timeout_seconds = max(0, int(doc.get("timeout_seconds") or 0))
@@ -3034,7 +3107,8 @@ def scripts_quick_run():
"script_type": script_type,
"script_name": _safe_filename(rel_path),
"script_path": rel_path.replace(os.sep, "/"),
"script_content": content,
"script_content": encoded_content,
"script_encoding": "base64",
"environment": env_map,
"variables": variables,
"timeout_seconds": timeout_seconds,
@@ -3070,6 +3144,7 @@ def ansible_quick_run():
return jsonify({"error": "Playbook not found"}), 404
doc = _load_assembly_document(abs_path, 'ansible')
content = doc.get('script') or ''
encoded_content = _encode_script_content(content)
variables = doc.get('variables') if isinstance(doc.get('variables'), list) else []
files = doc.get('files') if isinstance(doc.get('files'), list) else []
@@ -3112,7 +3187,8 @@ def ansible_quick_run():
"run_id": run_id,
"target_hostname": str(host),
"playbook_name": os.path.basename(abs_path),
"playbook_content": content,
"playbook_content": encoded_content,
"playbook_encoding": "base64",
"connection": "winrm",
"variables": variables,
"files": files,