From 347eef91e48b35a359ed39014a731ab57671a639 Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Mon, 2 Mar 2026 20:10:54 +0100 Subject: [PATCH 01/13] feat: sec hardening - passlib -> pwdlib - preciser limit enforcements for k8s pods, also psa labels - bumped up dependencies (last ones got CVEs inside), also added up passwords --- backend/app/core/security.py | 15 ++- .../app/services/k8s_worker/pod_builder.py | 2 + backend/app/services/k8s_worker/worker.py | 99 +++++++++++++++++++ backend/app/settings.py | 1 + backend/pyproject.toml | 2 +- backend/scripts/seed_users.py | 9 +- backend/secrets.example.toml | 1 + backend/uv.lock | 77 ++++++++++++--- backend/workers/run_k8s_worker.py | 3 +- docker-compose.yaml | 8 +- frontend/nginx.conf.template | 2 +- 11 files changed, 189 insertions(+), 30 deletions(-) diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 93e5c4dc..6a70321d 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -6,7 +6,8 @@ import jwt from fastapi import Request from fastapi.security import OAuth2PasswordBearer -from passlib.context import CryptContext +from pwdlib import PasswordHash +from pwdlib.hashers.bcrypt import BcryptHasher from app.core.metrics import SecurityMetrics from app.domain.user import CSRFValidationError, InvalidCredentialsError @@ -20,17 +21,15 @@ def __init__(self, settings: Settings, security_metrics: SecurityMetrics) -> Non self.settings = settings self._security_metrics = security_metrics # --8<-- [start:password_hashing] - self.pwd_context = CryptContext( - schemes=["bcrypt"], - deprecated="auto", - bcrypt__rounds=self.settings.BCRYPT_ROUNDS, - ) + self._password_hash = PasswordHash(( + BcryptHasher(rounds=self.settings.BCRYPT_ROUNDS), + )) def verify_password(self, plain_password: str, hashed_password: str) -> bool: - return self.pwd_context.verify(plain_password, hashed_password) # type: ignore + return self._password_hash.verify(plain_password, hashed_password) def get_password_hash(self, password: str) -> str: - return self.pwd_context.hash(password) # type: ignore + return self._password_hash.hash(password) # --8<-- [end:password_hashing] # --8<-- [start:create_access_token] diff --git a/backend/app/services/k8s_worker/pod_builder.py b/backend/app/services/k8s_worker/pod_builder.py index 1579fdee..65e4af2d 100644 --- a/backend/app/services/k8s_worker/pod_builder.py +++ b/backend/app/services/k8s_worker/pod_builder.py @@ -106,6 +106,8 @@ def _build_pod_spec( containers=[container], restart_policy="Never", active_deadline_seconds=timeout, + runtime_class_name=self._settings.K8S_POD_RUNTIME_CLASS_NAME, + host_users=False, # User namespace isolation — remaps container UIDs to unprivileged host UIDs volumes=[ k8s_client.V1Volume( name="script-volume", diff --git a/backend/app/services/k8s_worker/worker.py b/backend/app/services/k8s_worker/worker.py index 14fbff23..6a471bcc 100644 --- a/backend/app/services/k8s_worker/worker.py +++ b/backend/app/services/k8s_worker/worker.py @@ -56,6 +56,7 @@ def __init__( # Kubernetes clients created from ApiClient self.v1 = k8s_client.CoreV1Api(api_client) self.apps_v1 = k8s_client.AppsV1Api(api_client) + self.networking_v1 = k8s_client.NetworkingV1Api(api_client) # Components self.pod_builder = PodBuilder(settings=settings) @@ -252,6 +253,104 @@ async def _publish_pod_creation_failed(self, command: CreatePodCommandEvent, err ) await self.producer.produce(event_to_produce=event, key=command.execution_id) + async def ensure_namespace_security(self) -> None: + """Apply security controls to the executor namespace at startup. + + Creates: + - Default-deny NetworkPolicy for executor pods (blocks lateral movement and exfiltration) + - ResourceQuota to cap aggregate pod/resource consumption + - Pod Security Admission labels (Restricted profile) + """ + namespace = self._settings.K8S_NAMESPACE + await self._ensure_executor_network_policy(namespace) + await self._ensure_executor_resource_quota(namespace) + await self._apply_psa_labels(namespace) + + async def _ensure_executor_network_policy(self, namespace: str) -> None: + """Create default-deny NetworkPolicy for executor pods.""" + policy_name = "executor-deny-all" + + policy = k8s_client.V1NetworkPolicy( + api_version="networking.k8s.io/v1", + kind="NetworkPolicy", + metadata=k8s_client.V1ObjectMeta( + name=policy_name, + namespace=namespace, + labels={"app": "integr8s", "component": "security"}, + ), + spec=k8s_client.V1NetworkPolicySpec( + pod_selector=k8s_client.V1LabelSelector( + match_labels={"component": "executor"}, + ), + policy_types=["Ingress", "Egress"], + ingress=[], + egress=[], + ), + ) + + try: + await self.networking_v1.read_namespaced_network_policy(name=policy_name, namespace=namespace) + await self.networking_v1.replace_namespaced_network_policy( + name=policy_name, namespace=namespace, body=policy, + ) + self.logger.info(f"NetworkPolicy '{policy_name}' updated in namespace {namespace}") + except ApiException as e: + if e.status == 404: + await self.networking_v1.create_namespaced_network_policy(namespace=namespace, body=policy) + self.logger.info(f"NetworkPolicy '{policy_name}' created in namespace {namespace}") + else: + self.logger.error(f"Failed to apply NetworkPolicy '{policy_name}': {e.reason}") + + async def _ensure_executor_resource_quota(self, namespace: str) -> None: + """Create ResourceQuota to cap aggregate executor pod consumption.""" + quota_name = "executor-quota" + + quota = k8s_client.V1ResourceQuota( + api_version="v1", + kind="ResourceQuota", + metadata=k8s_client.V1ObjectMeta( + name=quota_name, + namespace=namespace, + labels={"app": "integr8s", "component": "security"}, + ), + spec=k8s_client.V1ResourceQuotaSpec( + hard={ + "pods": str(self._settings.K8S_MAX_CONCURRENT_PODS), + "requests.cpu": f"{self._settings.K8S_MAX_CONCURRENT_PODS}", + "requests.memory": f"{self._settings.K8S_MAX_CONCURRENT_PODS * 128}Mi", + "limits.cpu": f"{self._settings.K8S_MAX_CONCURRENT_PODS}", + "limits.memory": f"{self._settings.K8S_MAX_CONCURRENT_PODS * 128}Mi", + }, + ), + ) + + try: + await self.v1.read_namespaced_resource_quota(name=quota_name, namespace=namespace) + await self.v1.replace_namespaced_resource_quota(name=quota_name, namespace=namespace, body=quota) + self.logger.info(f"ResourceQuota '{quota_name}' updated in namespace {namespace}") + except ApiException as e: + if e.status == 404: + await self.v1.create_namespaced_resource_quota(namespace=namespace, body=quota) + self.logger.info(f"ResourceQuota '{quota_name}' created in namespace {namespace}") + else: + self.logger.error(f"Failed to apply ResourceQuota '{quota_name}': {e.reason}") + + async def _apply_psa_labels(self, namespace: str) -> None: + """Apply Pod Security Admission labels to the executor namespace.""" + psa_labels = { + "pod-security.kubernetes.io/enforce": "restricted", + "pod-security.kubernetes.io/enforce-version": "latest", + "pod-security.kubernetes.io/warn": "restricted", + "pod-security.kubernetes.io/audit": "restricted", + } + + patch_body = {"metadata": {"labels": psa_labels}} + try: + await self.v1.patch_namespace(name=namespace, body=patch_body) + self.logger.info(f"Pod Security Admission labels applied to namespace {namespace}") + except ApiException as e: + self.logger.error(f"Failed to apply PSA labels to namespace {namespace}: {e.reason}") + async def ensure_image_pre_puller_daemonset(self) -> None: """Ensure the runtime image pre-puller DaemonSet exists.""" daemonset_name = "runtime-image-pre-puller" diff --git a/backend/app/settings.py b/backend/app/settings.py index 61dbcaca..35e9c1f8 100644 --- a/backend/app/settings.py +++ b/backend/app/settings.py @@ -78,6 +78,7 @@ def __init__( K8S_POD_MEMORY_REQUEST: str = "128Mi" K8S_POD_EXECUTION_TIMEOUT: int = 300 # in seconds K8S_POD_PRIORITY_CLASS_NAME: str | None = None + K8S_POD_RUNTIME_CLASS_NAME: str | None = None # e.g. "gvisor" for sandboxed execution SUPPORTED_RUNTIMES: dict[str, LanguageInfoDomain] = Field(default_factory=lambda: RUNTIME_MATRIX) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 8b646ab3..8576fed5 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -70,7 +70,7 @@ dependencies = [ "opentelemetry-util-http==0.60b1", "packaging==24.1", "beanie==2.0.1", - "passlib==1.7.4", + "pwdlib[bcrypt]==0.2.1", "pathspec==0.12.1", "prometheus-fastapi-instrumentator==7.0.0", "prometheus_client==0.21.0", diff --git a/backend/scripts/seed_users.py b/backend/scripts/seed_users.py index e4f5d6d1..5378e22c 100755 --- a/backend/scripts/seed_users.py +++ b/backend/scripts/seed_users.py @@ -18,11 +18,12 @@ from app.settings import Settings from bson import ObjectId -from passlib.context import CryptContext +from pwdlib import PasswordHash +from pwdlib.hashers.bcrypt import BcryptHasher from pymongo.asynchronous.database import AsyncDatabase from pymongo.asynchronous.mongo_client import AsyncMongoClient -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +pwd_hasher = PasswordHash((BcryptHasher(rounds=12),)) async def upsert_user( @@ -42,7 +43,7 @@ async def upsert_user( {"username": username}, { "$set": { - "hashed_password": pwd_context.hash(password), + "hashed_password": pwd_hasher.hash(password), "role": role, "is_superuser": is_superuser, "is_active": True, @@ -58,7 +59,7 @@ async def upsert_user( "user_id": str(ObjectId()), "username": username, "email": email, - "hashed_password": pwd_context.hash(password), + "hashed_password": pwd_hasher.hash(password), "role": role, "is_active": True, "is_superuser": is_superuser, diff --git a/backend/secrets.example.toml b/backend/secrets.example.toml index 0f7b41ef..c12eb784 100644 --- a/backend/secrets.example.toml +++ b/backend/secrets.example.toml @@ -15,3 +15,4 @@ SECRET_KEY = "CHANGE_ME_min_32_chars_long_!!!!" MONGODB_URL = "mongodb://root:rootpassword@mongo:27017/integr8scode?authSource=admin" +REDIS_PASSWORD = "redispassword" diff --git a/backend/uv.lock b/backend/uv.lock index 6bd53ded..f8c8f62a 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -253,6 +253,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" }, ] +[[package]] +name = "bcrypt" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/5d/6d7433e0f3cd46ce0b43cd65e1db465ea024dbb8216fb2404e919c2ad77b/bcrypt-4.3.0.tar.gz", hash = "sha256:3a3fd2204178b6d2adcf09cb4f6426ffef54762577a7c9b54c159008cb288c18", size = 25697, upload-time = "2025-02-28T01:24:09.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/2c/3d44e853d1fe969d229bd58d39ae6902b3d924af0e2b5a60d17d4b809ded/bcrypt-4.3.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f01e060f14b6b57bbb72fc5b4a83ac21c443c9a2ee708e04a10e9192f90a6281", size = 483719, upload-time = "2025-02-28T01:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e2/58ff6e2a22eca2e2cff5370ae56dba29d70b1ea6fc08ee9115c3ae367795/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5eeac541cefd0bb887a371ef73c62c3cd78535e4887b310626036a7c0a817bb", size = 272001, upload-time = "2025-02-28T01:22:38.078Z" }, + { url = "https://files.pythonhosted.org/packages/37/1f/c55ed8dbe994b1d088309e366749633c9eb90d139af3c0a50c102ba68a1a/bcrypt-4.3.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59e1aa0e2cd871b08ca146ed08445038f42ff75968c7ae50d2fdd7860ade2180", size = 277451, upload-time = "2025-02-28T01:22:40.787Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1c/794feb2ecf22fe73dcfb697ea7057f632061faceb7dcf0f155f3443b4d79/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:0042b2e342e9ae3d2ed22727c1262f76cc4f345683b5c1715f0250cf4277294f", size = 272792, upload-time = "2025-02-28T01:22:43.144Z" }, + { url = "https://files.pythonhosted.org/packages/13/b7/0b289506a3f3598c2ae2bdfa0ea66969812ed200264e3f61df77753eee6d/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74a8d21a09f5e025a9a23e7c0fd2c7fe8e7503e4d356c0a2c1486ba010619f09", size = 289752, upload-time = "2025-02-28T01:22:45.56Z" }, + { url = "https://files.pythonhosted.org/packages/dc/24/d0fb023788afe9e83cc118895a9f6c57e1044e7e1672f045e46733421fe6/bcrypt-4.3.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:0142b2cb84a009f8452c8c5a33ace5e3dfec4159e7735f5afe9a4d50a8ea722d", size = 277762, upload-time = "2025-02-28T01:22:47.023Z" }, + { url = "https://files.pythonhosted.org/packages/e4/38/cde58089492e55ac4ef6c49fea7027600c84fd23f7520c62118c03b4625e/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:12fa6ce40cde3f0b899729dbd7d5e8811cb892d31b6f7d0334a1f37748b789fd", size = 272384, upload-time = "2025-02-28T01:22:49.221Z" }, + { url = "https://files.pythonhosted.org/packages/de/6a/d5026520843490cfc8135d03012a413e4532a400e471e6188b01b2de853f/bcrypt-4.3.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:5bd3cca1f2aa5dbcf39e2aa13dd094ea181f48959e1071265de49cc2b82525af", size = 277329, upload-time = "2025-02-28T01:22:51.603Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a3/4fc5255e60486466c389e28c12579d2829b28a527360e9430b4041df4cf9/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:335a420cfd63fc5bc27308e929bee231c15c85cc4c496610ffb17923abf7f231", size = 305241, upload-time = "2025-02-28T01:22:53.283Z" }, + { url = "https://files.pythonhosted.org/packages/c7/15/2b37bc07d6ce27cc94e5b10fd5058900eb8fb11642300e932c8c82e25c4a/bcrypt-4.3.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:0e30e5e67aed0187a1764911af023043b4542e70a7461ad20e837e94d23e1d6c", size = 309617, upload-time = "2025-02-28T01:22:55.461Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1f/99f65edb09e6c935232ba0430c8c13bb98cb3194b6d636e61d93fe60ac59/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b8d62290ebefd49ee0b3ce7500f5dbdcf13b81402c05f6dafab9a1e1b27212f", size = 335751, upload-time = "2025-02-28T01:22:57.81Z" }, + { url = "https://files.pythonhosted.org/packages/00/1b/b324030c706711c99769988fcb694b3cb23f247ad39a7823a78e361bdbb8/bcrypt-4.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef6630e0ec01376f59a006dc72918b1bf436c3b571b80fa1968d775fa02fe7d", size = 355965, upload-time = "2025-02-28T01:22:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/20372a0579dd915dfc3b1cd4943b3bca431866fcb1dfdfd7518c3caddea6/bcrypt-4.3.0-cp313-cp313t-win32.whl", hash = "sha256:7a4be4cbf241afee43f1c3969b9103a41b40bcb3a3f467ab19f891d9bc4642e4", size = 155316, upload-time = "2025-02-28T01:23:00.763Z" }, + { url = "https://files.pythonhosted.org/packages/6d/52/45d969fcff6b5577c2bf17098dc36269b4c02197d551371c023130c0f890/bcrypt-4.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c1949bf259a388863ced887c7861da1df681cb2388645766c89fdfd9004c669", size = 147752, upload-time = "2025-02-28T01:23:02.908Z" }, + { url = "https://files.pythonhosted.org/packages/11/22/5ada0b9af72b60cbc4c9a399fdde4af0feaa609d27eb0adc61607997a3fa/bcrypt-4.3.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:f81b0ed2639568bf14749112298f9e4e2b28853dab50a8b357e31798686a036d", size = 498019, upload-time = "2025-02-28T01:23:05.838Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8c/252a1edc598dc1ce57905be173328eda073083826955ee3c97c7ff5ba584/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:864f8f19adbe13b7de11ba15d85d4a428c7e2f344bac110f667676a0ff84924b", size = 279174, upload-time = "2025-02-28T01:23:07.274Z" }, + { url = "https://files.pythonhosted.org/packages/29/5b/4547d5c49b85f0337c13929f2ccbe08b7283069eea3550a457914fc078aa/bcrypt-4.3.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e36506d001e93bffe59754397572f21bb5dc7c83f54454c990c74a468cd589e", size = 283870, upload-time = "2025-02-28T01:23:09.151Z" }, + { url = "https://files.pythonhosted.org/packages/be/21/7dbaf3fa1745cb63f776bb046e481fbababd7d344c5324eab47f5ca92dd2/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:842d08d75d9fe9fb94b18b071090220697f9f184d4547179b60734846461ed59", size = 279601, upload-time = "2025-02-28T01:23:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6d/64/e042fc8262e971347d9230d9abbe70d68b0a549acd8611c83cebd3eaec67/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7c03296b85cb87db865d91da79bf63d5609284fc0cab9472fdd8367bbd830753", size = 297660, upload-time = "2025-02-28T01:23:12.989Z" }, + { url = "https://files.pythonhosted.org/packages/50/b8/6294eb84a3fef3b67c69b4470fcdd5326676806bf2519cda79331ab3c3a9/bcrypt-4.3.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:62f26585e8b219cdc909b6a0069efc5e4267e25d4a3770a364ac58024f62a761", size = 284083, upload-time = "2025-02-28T01:23:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/62/e6/baff635a4f2c42e8788fe1b1633911c38551ecca9a749d1052d296329da6/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:beeefe437218a65322fbd0069eb437e7c98137e08f22c4660ac2dc795c31f8bb", size = 279237, upload-time = "2025-02-28T01:23:16.686Z" }, + { url = "https://files.pythonhosted.org/packages/39/48/46f623f1b0c7dc2e5de0b8af5e6f5ac4cc26408ac33f3d424e5ad8da4a90/bcrypt-4.3.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:97eea7408db3a5bcce4a55d13245ab3fa566e23b4c67cd227062bb49e26c585d", size = 283737, upload-time = "2025-02-28T01:23:18.897Z" }, + { url = "https://files.pythonhosted.org/packages/49/8b/70671c3ce9c0fca4a6cc3cc6ccbaa7e948875a2e62cbd146e04a4011899c/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:191354ebfe305e84f344c5964c7cd5f924a3bfc5d405c75ad07f232b6dffb49f", size = 312741, upload-time = "2025-02-28T01:23:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/27/fb/910d3a1caa2d249b6040a5caf9f9866c52114d51523ac2fb47578a27faee/bcrypt-4.3.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:41261d64150858eeb5ff43c753c4b216991e0ae16614a308a15d909503617732", size = 316472, upload-time = "2025-02-28T01:23:23.183Z" }, + { url = "https://files.pythonhosted.org/packages/dc/cf/7cf3a05b66ce466cfb575dbbda39718d45a609daa78500f57fa9f36fa3c0/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:33752b1ba962ee793fa2b6321404bf20011fe45b9afd2a842139de3011898fef", size = 343606, upload-time = "2025-02-28T01:23:25.361Z" }, + { url = "https://files.pythonhosted.org/packages/e3/b8/e970ecc6d7e355c0d892b7f733480f4aa8509f99b33e71550242cf0b7e63/bcrypt-4.3.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:50e6e80a4bfd23a25f5c05b90167c19030cf9f87930f7cb2eacb99f45d1c3304", size = 362867, upload-time = "2025-02-28T01:23:26.875Z" }, + { url = "https://files.pythonhosted.org/packages/a9/97/8d3118efd8354c555a3422d544163f40d9f236be5b96c714086463f11699/bcrypt-4.3.0-cp38-abi3-win32.whl", hash = "sha256:67a561c4d9fb9465ec866177e7aebcad08fe23aaf6fbd692a6fab69088abfc51", size = 160589, upload-time = "2025-02-28T01:23:28.381Z" }, + { url = "https://files.pythonhosted.org/packages/29/07/416f0b99f7f3997c69815365babbc2e8754181a4b1899d921b3c7d5b6f12/bcrypt-4.3.0-cp38-abi3-win_amd64.whl", hash = "sha256:584027857bc2843772114717a7490a37f68da563b3620f78a849bcb54dc11e62", size = 152794, upload-time = "2025-02-28T01:23:30.187Z" }, + { url = "https://files.pythonhosted.org/packages/6e/c1/3fa0e9e4e0bfd3fd77eb8b52ec198fd6e1fd7e9402052e43f23483f956dd/bcrypt-4.3.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0d3efb1157edebfd9128e4e46e2ac1a64e0c1fe46fb023158a407c7892b0f8c3", size = 498969, upload-time = "2025-02-28T01:23:31.945Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d4/755ce19b6743394787fbd7dff6bf271b27ee9b5912a97242e3caf125885b/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08bacc884fd302b611226c01014eca277d48f0a05187666bca23aac0dad6fe24", size = 279158, upload-time = "2025-02-28T01:23:34.161Z" }, + { url = "https://files.pythonhosted.org/packages/9b/5d/805ef1a749c965c46b28285dfb5cd272a7ed9fa971f970435a5133250182/bcrypt-4.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6746e6fec103fcd509b96bacdfdaa2fbde9a553245dbada284435173a6f1aef", size = 284285, upload-time = "2025-02-28T01:23:35.765Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/698580547a4a4988e415721b71eb45e80c879f0fb04a62da131f45987b96/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:afe327968aaf13fc143a56a3360cb27d4ad0345e34da12c7290f1b00b8fe9a8b", size = 279583, upload-time = "2025-02-28T01:23:38.021Z" }, + { url = "https://files.pythonhosted.org/packages/f2/87/62e1e426418204db520f955ffd06f1efd389feca893dad7095bf35612eec/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d9af79d322e735b1fc33404b5765108ae0ff232d4b54666d46730f8ac1a43676", size = 297896, upload-time = "2025-02-28T01:23:39.575Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c6/8fedca4c2ada1b6e889c52d2943b2f968d3427e5d65f595620ec4c06fa2f/bcrypt-4.3.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f1e3ffa1365e8702dc48c8b360fef8d7afeca482809c5e45e653af82ccd088c1", size = 284492, upload-time = "2025-02-28T01:23:40.901Z" }, + { url = "https://files.pythonhosted.org/packages/4d/4d/c43332dcaaddb7710a8ff5269fcccba97ed3c85987ddaa808db084267b9a/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3004df1b323d10021fda07a813fd33e0fd57bef0e9a480bb143877f6cba996fe", size = 279213, upload-time = "2025-02-28T01:23:42.653Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/1e36379e169a7df3a14a1c160a49b7b918600a6008de43ff20d479e6f4b5/bcrypt-4.3.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:531457e5c839d8caea9b589a1bcfe3756b0547d7814e9ce3d437f17da75c32b0", size = 284162, upload-time = "2025-02-28T01:23:43.964Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0a/644b2731194b0d7646f3210dc4d80c7fee3ecb3a1f791a6e0ae6bb8684e3/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:17a854d9a7a476a89dcef6c8bd119ad23e0f82557afbd2c442777a16408e614f", size = 312856, upload-time = "2025-02-28T01:23:46.011Z" }, + { url = "https://files.pythonhosted.org/packages/dc/62/2a871837c0bb6ab0c9a88bf54de0fc021a6a08832d4ea313ed92a669d437/bcrypt-4.3.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6fb1fd3ab08c0cbc6826a2e0447610c6f09e983a281b919ed721ad32236b8b23", size = 316726, upload-time = "2025-02-28T01:23:47.575Z" }, + { url = "https://files.pythonhosted.org/packages/0c/a1/9898ea3faac0b156d457fd73a3cb9c2855c6fd063e44b8522925cdd8ce46/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e965a9c1e9a393b8005031ff52583cedc15b7884fce7deb8b0346388837d6cfe", size = 343664, upload-time = "2025-02-28T01:23:49.059Z" }, + { url = "https://files.pythonhosted.org/packages/40/f2/71b4ed65ce38982ecdda0ff20c3ad1b15e71949c78b2c053df53629ce940/bcrypt-4.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:79e70b8342a33b52b55d93b3a59223a844962bef479f6a0ea318ebbcadf71505", size = 363128, upload-time = "2025-02-28T01:23:50.399Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/12f6a58eca6dea4be992d6c681b7ec9410a1d9f5cf368c61437e31daa879/bcrypt-4.3.0-cp39-abi3-win32.whl", hash = "sha256:b4d4e57f0a63fd0b358eb765063ff661328f69a04494427265950c71b992a39a", size = 160598, upload-time = "2025-02-28T01:23:51.775Z" }, + { url = "https://files.pythonhosted.org/packages/a9/cf/45fb5261ece3e6b9817d3d82b2f343a505fd58674a92577923bc500bd1aa/bcrypt-4.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:e53e074b120f2877a35cc6c736b8eb161377caae8925c17688bd46ba56daaa5b", size = 152799, upload-time = "2025-02-28T01:23:53.139Z" }, +] + [[package]] name = "beanie" version = "2.0.1" @@ -1257,13 +1307,13 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "opentelemetry-util-http" }, { name = "packaging" }, - { name = "passlib" }, { name = "pathspec" }, { name = "prometheus-client" }, { name = "prometheus-fastapi-instrumentator" }, { name = "propcache" }, { name = "protobuf" }, { name = "psutil" }, + { name = "pwdlib", extra = ["bcrypt"] }, { name = "pyasn1" }, { name = "pyasn1-modules" }, { name = "pydantic" }, @@ -1417,13 +1467,13 @@ requires-dist = [ { name = "opentelemetry-semantic-conventions", specifier = "==0.60b1" }, { name = "opentelemetry-util-http", specifier = "==0.60b1" }, { name = "packaging", specifier = "==24.1" }, - { name = "passlib", specifier = "==1.7.4" }, { name = "pathspec", specifier = "==0.12.1" }, { name = "prometheus-client", specifier = "==0.21.0" }, { name = "prometheus-fastapi-instrumentator", specifier = "==7.0.0" }, { name = "propcache", specifier = "==0.4.1" }, { name = "protobuf", specifier = "==6.33.5" }, { name = "psutil", specifier = "==7.2.2" }, + { name = "pwdlib", extras = ["bcrypt"], specifier = "==0.2.1" }, { name = "pyasn1", specifier = "==0.6.2" }, { name = "pyasn1-modules", specifier = "==0.4.2" }, { name = "pydantic", specifier = "==2.9.2" }, @@ -2321,15 +2371,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/aa/cc0199a5f0ad350994d660967a8efb233fe0416e4639146c089643407ce6/packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124", size = 53985, upload-time = "2024-06-09T23:19:21.909Z" }, ] -[[package]] -name = "passlib" -version = "1.7.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04", size = 689844, upload-time = "2020-10-08T19:00:52.121Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1", size = 525554, upload-time = "2020-10-08T19:00:49.856Z" }, -] - [[package]] name = "pathspec" version = "0.12.1" @@ -2588,6 +2629,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] +[[package]] +name = "pwdlib" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/a0/9daed437a6226f632a25d98d65d60ba02bdafa920c90dcb6454c611ead6c/pwdlib-0.2.1.tar.gz", hash = "sha256:9a1d8a8fa09a2f7ebf208265e55d7d008103cbdc82b9e4902ffdd1ade91add5e", size = 11699, upload-time = "2024-08-19T06:48:59.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/f3/0dae5078a486f0fdf4d4a1121e103bc42694a9da9bea7b0f2c63f29cfbd3/pwdlib-0.2.1-py3-none-any.whl", hash = "sha256:1823dc6f22eae472b540e889ecf57fd424051d6a4023ec0bcf7f0de2d9d7ef8c", size = 8082, upload-time = "2024-08-19T06:49:00.997Z" }, +] + +[package.optional-dependencies] +bcrypt = [ + { name = "bcrypt" }, +] + [[package]] name = "pyasn1" version = "0.6.2" diff --git a/backend/workers/run_k8s_worker.py b/backend/workers/run_k8s_worker.py index b69a84c9..4e47d5b6 100644 --- a/backend/workers/run_k8s_worker.py +++ b/backend/workers/run_k8s_worker.py @@ -44,8 +44,9 @@ async def run() -> None: async def init_k8s_worker() -> None: worker = await container.get(KubernetesWorker) + await worker.ensure_namespace_security() await worker.ensure_image_pre_puller_daemonset() - logger.info("KubernetesWorker initialized with pre-puller daemonset") + logger.info("KubernetesWorker initialized with namespace security and pre-puller daemonset") app = FastStream(broker, on_startup=[init_k8s_worker], on_shutdown=[container.close]) await app.run() diff --git a/docker-compose.yaml b/docker-compose.yaml index c89bfe2c..1f017ce5 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -40,7 +40,7 @@ services: condition: service_completed_successfully mongo: - image: mongo:8.0 + image: mongo:8.0.17 command: ["mongod", "--wiredTigerCacheSizeGB", "0.4"] ports: - "127.0.0.1:27017:27017" @@ -67,11 +67,11 @@ services: start_period: 5s redis: - image: redis:7-alpine + image: redis:7.4.6-alpine container_name: redis ports: - "127.0.0.1:6379:6379" - command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru --save "" + command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru --save "" --requirepass ${REDIS_PASSWORD:-redispassword} volumes: - redis_data:/data networks: @@ -79,7 +79,7 @@ services: mem_limit: 300m restart: unless-stopped healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-redispassword}", "--no-auth-warning", "ping"] interval: 2s timeout: 3s retries: 10 diff --git a/frontend/nginx.conf.template b/frontend/nginx.conf.template index a0284f90..143630a0 100644 --- a/frontend/nginx.conf.template +++ b/frontend/nginx.conf.template @@ -14,7 +14,7 @@ server { # --8<-- [end:server_block] # --8<-- [start:security_headers] - add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; connect-src 'self';"; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-Pkom4KvU0VljnDtiOAuXdz1y9CQQFryJJLwdtlZ9fZg='; style-src 'self' 'sha256-sWLv0aySm7JTFhODjborN/q7xArZ+wUwhzTHzamJ2+4=' 'sha256-QpmcrFOkZac1E5hPaOFYcWTG9FtSYScZl4hAh4XuqZU=' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; connect-src 'self';"; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-Frame-Options "DENY"; add_header X-Content-Type-Options "nosniff"; From d46ae062e756b95385d6b52ecfc27ab7fbc6ec04 Mon Sep 17 00:00:00 2001 From: HardMax71 Date: Mon, 2 Mar 2026 21:33:01 +0100 Subject: [PATCH 02/13] feat: csp nonce + replaced ansi-to-html with ansi_up - better class application --- .github/workflows/release-deploy.yml | 8 +- docs/SECURITY.md | 49 +++- docs/operations/nginx-configuration.md | 210 ++++++++++-------- docs/security/policies.md | 81 +++++-- frontend/nginx.conf.template | 8 +- frontend/package-lock.json | 25 +-- frontend/package.json | 2 +- frontend/public/index.html | 24 +- frontend/src/app.css | 41 ++++ .../components/editor/CodeMirrorEditor.svelte | 5 + .../src/components/editor/OutputPanel.svelte | 18 +- .../editor/__tests__/CodeMirrorEditor.test.ts | 1 + 12 files changed, 320 insertions(+), 152 deletions(-) diff --git a/.github/workflows/release-deploy.yml b/.github/workflows/release-deploy.yml index a72a3e07..a1d213ba 100644 --- a/.github/workflows/release-deploy.yml +++ b/.github/workflows/release-deploy.yml @@ -129,11 +129,14 @@ jobs: MAILJET_FROM_ADDRESS: ${{ secrets.MAILJET_FROM_ADDRESS }} MAILJET_HOST: ${{ secrets.MAILJET_HOST }} GRAFANA_ALERT_RECIPIENTS: ${{ secrets.GRAFANA_ALERT_RECIPIENTS }} + REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }} + MONGO_ROOT_USER: ${{ secrets.MONGO_ROOT_USER }} + MONGO_ROOT_PASSWORD: ${{ secrets.MONGO_ROOT_PASSWORD }} with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} - envs: GHCR_TOKEN,GHCR_USER,IMAGE_TAG,GRAFANA_ADMIN_USER,GRAFANA_ADMIN_PASSWORD,MAILJET_API_KEY,MAILJET_SECRET_KEY,MAILJET_FROM_ADDRESS,MAILJET_HOST,GRAFANA_ALERT_RECIPIENTS + envs: GHCR_TOKEN,GHCR_USER,IMAGE_TAG,GRAFANA_ADMIN_USER,GRAFANA_ADMIN_PASSWORD,MAILJET_API_KEY,MAILJET_SECRET_KEY,MAILJET_FROM_ADDRESS,MAILJET_HOST,GRAFANA_ALERT_RECIPIENTS,REDIS_PASSWORD,MONGO_ROOT_USER,MONGO_ROOT_PASSWORD command_timeout: 10m script: | set -e @@ -153,6 +156,9 @@ jobs: export MAILJET_FROM_ADDRESS="$MAILJET_FROM_ADDRESS" export GF_SMTP_HOST="$MAILJET_HOST" export GRAFANA_ALERT_RECIPIENTS="$GRAFANA_ALERT_RECIPIENTS" + export REDIS_PASSWORD="$REDIS_PASSWORD" + export MONGO_ROOT_USER="$MONGO_ROOT_USER" + export MONGO_ROOT_PASSWORD="$MONGO_ROOT_PASSWORD" docker compose pull docker compose up -d --remove-orphans --no-build --wait --wait-timeout 180 diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 410e73a1..1bdf51dc 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -4,16 +4,55 @@ Security patches go into `main` and the latest release. If you're running someth ## Reporting vulnerabilities -Found a security issue? Don't open a public GitHub issue - email [max.azatian@gmail.com](mailto:max.azatian@gmail.com) instead. +Found a security issue? Don't open a public GitHub issue - email [max.azatian@gmail.com](mailto:max.azatian@gmail.com) +instead. -Include what you can: vulnerability type, where it occurs, reproduction steps, PoC if you have one. You'll get an acknowledgment within 48 hours. If confirmed, we'll patch it and credit you in the disclosure (unless you prefer to stay anonymous). +Include what you can: vulnerability type, where it occurs, reproduction steps, PoC if you have one. You'll get an +acknowledgment within 48 hours. If confirmed, we'll patch it and credit you in the disclosure (unless you prefer to stay +anonymous). ## Automated scanning -The CI pipeline runs [Bandit](https://bandit.readthedocs.io/) on the Python backend for static analysis, and [Dependabot](https://docs.github.com/en/code-security/dependabot) keeps dependencies patched across Python, npm, and Docker. For SBOM generation and vulnerability scanning, see [Supply Chain Security](security/supply-chain.md). +The CI pipeline runs [Bandit](https://bandit.readthedocs.io/) on the Python backend for static analysis, +and [Dependabot](https://docs.github.com/en/code-security/dependabot) keeps dependencies patched across Python, npm, and +Docker. For SBOM generation and vulnerability scanning, see [Supply Chain Security](security/supply-chain.md). + +## Frontend hardening + +The frontend uses a nonce-based Content Security Policy: + +- **`script-src 'nonce-...'`** — blocks injected ` + -
-
-
- +
+
+
- diff --git a/frontend/src/app.css b/frontend/src/app.css index cacacc20..98a0b3df 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -579,4 +579,45 @@ @apply px-2 py-1 text-sm border border-border-input dark:border-dark-border-input rounded bg-surface-overlay dark:bg-dark-surface-overlay text-fg-default dark:text-dark-fg-default; } + + /* ANSI color classes for ansi_up (class-based mode) */ + .ansi-black-fg { color: #000; } + .ansi-red-fg { color: #C00; } + .ansi-green-fg { color: #0C0; } + .ansi-yellow-fg { color: #C50; } + .ansi-blue-fg { color: #00C; } + .ansi-magenta-fg { color: #C0C; } + .ansi-cyan-fg { color: #0CC; } + .ansi-white-fg { color: #CCC; } + .ansi-bright-black-fg { color: #555; } + .ansi-bright-red-fg { color: #F55; } + .ansi-bright-green-fg { color: #5F5; } + .ansi-bright-yellow-fg { color: #FF5; } + .ansi-bright-blue-fg { color: #55F; } + .ansi-bright-magenta-fg { color: #F5F; } + .ansi-bright-cyan-fg { color: #5FF; } + .ansi-bright-white-fg { color: #FFF; } + .ansi-black-bg { background-color: #000; } + .ansi-red-bg { background-color: #C00; } + .ansi-green-bg { background-color: #0C0; } + .ansi-yellow-bg { background-color: #C50; } + .ansi-blue-bg { background-color: #00C; } + .ansi-magenta-bg { background-color: #C0C; } + .ansi-cyan-bg { background-color: #0CC; } + .ansi-white-bg { background-color: #CCC; } + .ansi-bright-black-bg { background-color: #555; } + .ansi-bright-red-bg { background-color: #F55; } + .ansi-bright-green-bg { background-color: #5F5; } + .ansi-bright-yellow-bg { background-color: #FF5; } + .ansi-bright-blue-bg { background-color: #55F; } + .ansi-bright-magenta-bg { background-color: #F5F; } + .ansi-bright-cyan-bg { background-color: #5FF; } + .ansi-bright-white-bg { background-color: #FFF; } + .ansi-bold { font-weight: bold; } + .ansi-italic { font-style: italic; } + .ansi-underline { text-decoration: underline; } + + :is(.dark) .ansi-black-fg { color: #555; } + :is(.dark) .ansi-white-fg { color: #e2e8f0; } + :is(.dark) .ansi-bright-white-fg { color: #FFF; } } diff --git a/frontend/src/components/editor/CodeMirrorEditor.svelte b/frontend/src/components/editor/CodeMirrorEditor.svelte index 41c2538d..53f8421e 100644 --- a/frontend/src/components/editor/CodeMirrorEditor.svelte +++ b/frontend/src/components/editor/CodeMirrorEditor.svelte @@ -23,6 +23,10 @@ let container: HTMLElement; let view: EditorView | null = null; + function getCspNonce(): string { + return document.querySelector('meta[name="csp-nonce"]')?.getAttribute('content') ?? ''; + } + const themeCompartment = new Compartment(); const fontSizeCompartment = new Compartment(); const tabSizeCompartment = new Compartment(); @@ -36,6 +40,7 @@ function getStaticExtensions() { return [ + EditorView.cspNonce.of(getCspNonce()), lineNumbersCompartment.of(settings.show_line_numbers ? lineNumbers() : []), highlightActiveLineGutter(), highlightActiveLine(), diff --git a/frontend/src/components/editor/OutputPanel.svelte b/frontend/src/components/editor/OutputPanel.svelte index b8fe9794..c89ab718 100644 --- a/frontend/src/components/editor/OutputPanel.svelte +++ b/frontend/src/components/editor/OutputPanel.svelte @@ -3,7 +3,7 @@ import type { ExecutionPhase } from '$lib/editor'; import Spinner from '$components/Spinner.svelte'; import { AlertTriangle, FileText, Copy } from '@lucide/svelte'; - import AnsiToHtml from 'ansi-to-html'; + import { AnsiUp } from 'ansi_up'; import DOMPurify from 'dompurify'; import { toast } from 'svelte-sonner'; @@ -13,18 +13,14 @@ error: string | null; } = $props(); - const ansiConverter = new AnsiToHtml({ - fg: '#000', bg: '#FFF', newline: true, escapeXML: true, stream: false, - colors: { - 0: '#000', 1: '#C00', 2: '#0C0', 3: '#C50', 4: '#00C', 5: '#C0C', 6: '#0CC', 7: '#CCC', - 8: '#555', 9: '#F55', 10: '#5F5', 11: '#FF5', 12: '#55F', 13: '#F5F', 14: '#5FF', 15: '#FFF' - } - }); + const ansiConverter = new AnsiUp(); + ansiConverter.use_classes = true; + ansiConverter.escape_html = true; function sanitize(html: string): string { return DOMPurify.sanitize(html, { ALLOWED_TAGS: ['span', 'br', 'div'], - ALLOWED_ATTR: ['class', 'style'] + ALLOWED_ATTR: ['class'] }); } @@ -99,7 +95,7 @@

Output:

-
{@html sanitize(ansiConverter.toHtml(result.stdout || ''))}
+
{@html sanitize(ansiConverter.ansi_to_html(result.stdout))}