From 8144005ecba801526f37665935f67e5b368d70db Mon Sep 17 00:00:00 2001 From: Auditor Date: Wed, 2 Sep 2026 22:50:23 +0200 Subject: [PATCH] audit: harden sandbox vs rm -rf globs/path traversal and nvme/mmcblk devices, config/audit/chain fixes; add LICENSE; +regression tests --- LICENSE | 21 ++++++ src/t100ai/analysis/chain_of_custody.py | 16 ++++- src/t100ai/core/audit.py | 15 ++++- src/t100ai/core/config.py | 23 +++++-- src/t100ai/core/engine.py | 4 +- src/t100ai/core/sandbox.py | 33 +++++++--- tests/test_audit_improvements.py | 85 +++++++++++++++++++++++++ tests/test_core.py | 6 +- tests/test_engine.py | 10 ++- 9 files changed, 186 insertions(+), 27 deletions(-) create mode 100644 LICENSE create mode 100644 tests/test_audit_improvements.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a3a3218 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Ruby570bocadito + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/t100ai/analysis/chain_of_custody.py b/src/t100ai/analysis/chain_of_custody.py index a4f0471..464b904 100644 --- a/src/t100ai/analysis/chain_of_custody.py +++ b/src/t100ai/analysis/chain_of_custody.py @@ -53,7 +53,21 @@ class ChainOfCustody: """ def __init__(self, secret: Optional[str] = None, engagement_id: str = ""): - self._secret = secret or os.environ.get("T100AI_AUDIT_SECRET", "change-me-in-production") + # Never fall back to a hardcoded secret: an attacker could forge signatures. + if secret: + self._secret = secret + elif os.environ.get("T100AI_AUDIT_SECRET"): + self._secret = os.environ["T100AI_AUDIT_SECRET"] + else: + import secrets as _secrets + import warnings as _warnings + self._secret = _secrets.token_hex(32) + _warnings.warn( + "T100AI_AUDIT_SECRET not set; using an ephemeral random secret. " + "Chain-of-custody signatures will NOT be verifiable across runs.", + RuntimeWarning, + stacklevel=2, + ) self._engagement_id = engagement_id self._chain: list[EvidenceItem] = [] self._storage_dir = Path("evidence") / engagement_id diff --git a/src/t100ai/core/audit.py b/src/t100ai/core/audit.py index 8abbfc0..5a77fb7 100644 --- a/src/t100ai/core/audit.py +++ b/src/t100ai/core/audit.py @@ -25,7 +25,20 @@ def __init__(self, log_dir: Optional[str] = None, secret: Optional[str] = None): os.makedirs(self.log_dir, exist_ok=True) self.log_path = os.path.join(self.log_dir, "audit.log") self.sig_path = os.path.join(self.log_dir, "audit.sig") - self._hmac_secret = secret or os.environ.get("T100AI_AUDIT_SECRET", "default-secret-change-me") + if secret: + self._hmac_secret = secret + elif os.environ.get("T100AI_AUDIT_SECRET"): + self._hmac_secret = os.environ["T100AI_AUDIT_SECRET"] + else: + import secrets as _secrets + import warnings as _warnings + self._hmac_secret = _secrets.token_hex(32) + _warnings.warn( + "T100AI_AUDIT_SECRET not set; using an ephemeral random secret. " + "Audit-log integrity will NOT be verifiable across runs.", + RuntimeWarning, + stacklevel=2, + ) def _read_all(self) -> List[Dict[str, Any]]: if not os.path.exists(self.log_path): diff --git a/src/t100ai/core/config.py b/src/t100ai/core/config.py index 5147675..b4a0c43 100644 --- a/src/t100ai/core/config.py +++ b/src/t100ai/core/config.py @@ -2,23 +2,32 @@ from pathlib import Path from typing import Optional, Literal -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, AliasChoices from pydantic_settings import BaseSettings import os class T100AIConfig(BaseSettings): - """Configuración principal de T-100AI""" + """Configuración principal de T-100AI + + Acepta dos prefijos de variables de entorno por compatibilidad: + ``OLLAMA_HOST``/``T100AI_OLLAMA_HOST``, ``OLLAMA_MODEL``/``T100AI_OLLAMA_MODEL``, + etc. (el README y el Dockerfile usan el prefijo ``T100AI_``). + """ # LLM Configuration - ollama_host: str = Field(default="http://localhost:11434", alias="OLLAMA_HOST") - ollama_model: str = Field(default="devstral-small-2:latest", alias="OLLAMA_MODEL") - llm_enabled: bool = Field(default=True, alias="LLM_ENABLED") + ollama_host: str = Field(default="http://localhost:11434", + validation_alias=AliasChoices("OLLAMA_HOST", "T100AI_OLLAMA_HOST")) + ollama_model: str = Field(default="mistral:7b", + validation_alias=AliasChoices("OLLAMA_MODEL", "T100AI_OLLAMA_MODEL")) + llm_enabled: bool = Field(default=True, + validation_alias=AliasChoices("LLM_ENABLED", "T100AI_LLM_ENABLED")) llm_temperature: float = Field(default=0.7, alias="LLM_TEMPERATURE") llm_context_window: int = Field(default=4096, alias="LLM_CONTEXT_WINDOW") - # Session Configuration - session_dir: Path = Field(default=Path("./sessions"), alias="SESSION_DIR") + # Session Configuration (T100AI_DATA_DIR es el nombre documentado en Docker/README) + session_dir: Path = Field(default=Path("./sessions"), + validation_alias=AliasChoices("SESSION_DIR", "T100AI_DATA_DIR")) log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = Field(default="INFO", alias="LOG_LEVEL") audit_log_enabled: bool = Field(default=True, alias="AUDIT_LOG_ENABLED") diff --git a/src/t100ai/core/engine.py b/src/t100ai/core/engine.py index 58b226e..c9d941d 100644 --- a/src/t100ai/core/engine.py +++ b/src/t100ai/core/engine.py @@ -51,9 +51,9 @@ def __init__(self, session: Session, config: T100AIConfig): self._last_generated_code: Optional[dict] = None self._cancel_requested = False self._permission_manager = PermissionManager(current_level=PermissionLevel.OBSERVATION) - self._audit_logger = AuditLogger(path="src/specter/log/audit.log") + self._audit_logger = AuditLogger(path="src/t100ai/log/audit.log") try: - setup_logging(level="INFO", log_file="src/specter/log/specter.log", json_output=True) + setup_logging(level="INFO", log_file="src/t100ai/log/specter.log", json_output=True) except Exception: pass diff --git a/src/t100ai/core/sandbox.py b/src/t100ai/core/sandbox.py index 6289124..a57b477 100644 --- a/src/t100ai/core/sandbox.py +++ b/src/t100ai/core/sandbox.py @@ -54,10 +54,11 @@ class CommandSandbox: r'\brm\s+(-rf|--recursive.*-f|-f.*--recursive|--no-preserve-root)\s+/$', r'\brm\s+(-r\s+-f|-f\s+-r)\s+/$', r'\brm\s+(-rf|--no-preserve-root)\s+/$', - r'\bdd\s+if=/dev/(zero|urandom|random)\s+of=/dev/sd', - r'\bdd\s+of=/dev/sd', - r'\bmkfs\.\w+\s+/dev/sd', - r'>\s*/dev/sd[a-z]', + r'\bdd\s+if=/dev/(zero|urandom|random)\s+of=/dev/(sd|hd|vd|xvd|nvme|mmcblk)', + r'\bdd\s+of=/dev/(sd|hd|vd|xvd|nvme|mmcblk)', + r'\bmkfs\.\w+\s+/dev/(sd|hd|vd|xvd|nvme|mmcblk)', + r'>\s*/dev/(sd|hd|vd|xvd|nvme|mmcblk)\w*', + r'\b(sgdisk\s+--?zap-all|wipefs\s+-a\s+/dev/|blkdiscard\s+/dev/)', # Fork bombs r':\(\)\{:\|:&\};:', r'\b:\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;', @@ -233,7 +234,11 @@ def validate(self, command: str, source: str = "llm") -> tuple[bool, str]: return True, "OK" def _is_rm_rf_root(self, command: str) -> bool: - """Semantic check for rm -rf / with any flag ordering.""" + """Semantic check for rm -rf targeting the filesystem root. + + Catches any flag ordering plus globs (``/*``) and path traversal + that normalizes back to ``/`` (e.g. ``/home/../..``). + """ import shlex try: parts = shlex.split(command) @@ -243,17 +248,25 @@ def _is_rm_rf_root(self, command: str) -> bool: return False has_r = False has_f = False - target_is_root = False - for i, part in enumerate(parts[1:], 1): + targets: list[str] = [] + for part in parts[1:]: if part.startswith("-"): flags = part.lstrip("-") if "r" in flags or "R" in flags: has_r = True if "f" in flags: has_f = True - elif part == "/": - target_is_root = True - return has_r and has_f and target_is_root + else: + targets.append(part) + if not (has_r and has_f): + return False + for target in targets: + if target in ("/", "/*", "/.", "/.."): + return True + # Normalize paths that resolve back to root (e.g. /home/../..) + if target.startswith("/") and os.path.normpath(target) == "/": + return True + return False def requires_confirmation(self, command: str) -> bool: """Determina si requiere confirmación del usuario.""" diff --git a/tests/test_audit_improvements.py b/tests/test_audit_improvements.py new file mode 100644 index 0000000..853bff2 --- /dev/null +++ b/tests/test_audit_improvements.py @@ -0,0 +1,85 @@ +"""Tests for the audit-improvements branch changes (security fixes).""" + +import os +import warnings + +import pytest + +from t100ai.core.sandbox import CommandSandbox +from t100ai.core.config import T100AIConfig +from t100ai.analysis.chain_of_custody import ChainOfCustody +from t100ai.core.audit import AuditLogger + + +@pytest.fixture +def sandbox(): + return CommandSandbox(timeout=5, rate_limit=0) + + +class TestSandboxHardening: + """Previously-bypassable destructive commands must now be blocked.""" + + @pytest.mark.parametrize( + "cmd", + [ + "rm -rf /", + "rm -rf /*", + "rm -rf /home/../..", + "rm -rf /tmp/../../", + "dd if=/dev/zero of=/dev/sda", + "dd if=/dev/zero of=/dev/nvme0n1", + "dd if=/dev/urandom of=/dev/vda", + "sgdisk --zap-all /dev/sda", + "wipefs -a /dev/sda", + "blkdiscard /dev/nvme0n1", + "mkfs.ext4 /dev/sda1", + ], + ) + def test_destructive_blocked(self, sandbox, cmd): + allowed, _ = sandbox.validate(cmd) + assert not allowed, f"expected blocked: {cmd}" + + def test_normal_rm_allowed(self, sandbox): + allowed, _ = sandbox.validate("rm /tmp/test.txt") + assert allowed + + +class TestSecretHandling: + def test_chain_of_custody_no_hardcoded_secret(self): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + coc = ChainOfCustody(engagement_id="t") + assert len(coc._secret) == 64 # 32 bytes hex + assert coc._secret != "change-me-in-production" + assert len(w) == 1 + + def test_chain_of_custody_explicit_secret(self): + coc = ChainOfCustody(secret="my-secret") + assert coc._secret == "my-secret" + + def test_audit_logger_no_hardcoded_secret(self, tmp_path): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + al = AuditLogger(log_dir=str(tmp_path)) + assert len(al._hmac_secret) == 64 + assert al._hmac_secret != "default-secret-change-me" + assert len(w) == 1 + + +class TestConfigAliases: + def test_default_model_is_real(self): + assert T100AIConfig().ollama_model == "mistral:7b" + + def test_t100ai_prefix_host_alias(self): + os.environ["T100AI_OLLAMA_HOST"] = "http://alias:11434" + try: + assert T100AIConfig().ollama_host == "http://alias:11434" + finally: + del os.environ["T100AI_OLLAMA_HOST"] + + def test_t100ai_data_dir_alias(self): + os.environ["T100AI_DATA_DIR"] = "/app" + try: + assert str(T100AIConfig().session_dir) == "/app" + finally: + del os.environ["T100AI_DATA_DIR"] diff --git a/tests/test_core.py b/tests/test_core.py index 0678a63..bd1a15a 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -59,10 +59,10 @@ class TestT100AIConfig: def test_default_config(self): """Test configuración por defecto""" import os - os.environ["OLLAMA_MODEL"] = "devstral-small-2:latest" + os.environ["OLLAMA_MODEL"] = "mistral:7b" config = T100AIConfig() assert config.ollama_host == "http://localhost:11434" - assert config.ollama_model == "devstral-small-2:latest" + assert config.ollama_model == "mistral:7b" assert config.llm_enabled is True assert config.permission_mode == "standard" del os.environ["OLLAMA_MODEL"] @@ -187,7 +187,7 @@ def sample_session(): def sample_config(): """Fixture de configuración de ejemplo""" import os - os.environ["OLLAMA_MODEL"] = "devstral-small-2:latest" + os.environ["OLLAMA_MODEL"] = "mistral:7b" os.environ["LLM_ENABLED"] = "false" os.environ["PERMISSION_MODE"] = "standard" cfg = T100AIConfig() diff --git a/tests/test_engine.py b/tests/test_engine.py index 2a5deec..32abd03 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -12,13 +12,17 @@ class TestConfig: - def test_default_model_is_devstral(self): + def test_model_from_env(self): import os - os.environ["OLLAMA_MODEL"] = "devstral-small-2:latest" + os.environ["OLLAMA_MODEL"] = "mistral:7b" cfg = T100AIConfig() - assert cfg.ollama_model == "devstral-small-2:latest" + assert cfg.ollama_model == "mistral:7b" del os.environ["OLLAMA_MODEL"] + def test_default_model_is_real(self): + cfg = T100AIConfig() + assert cfg.ollama_model == "mistral:7b" + def test_default_host(self): cfg = T100AIConfig() assert cfg.ollama_host == "http://localhost:11434"