Some checks failed
Nix / nix (ubuntu-latest) (pull_request) Failing after 15s
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Failing after 19s
Docker Build and Publish / build-and-push (pull_request) Failing after 28s
Tests / test (pull_request) Failing after 9m43s
Nix / nix (macos-latest) (pull_request) Has been cancelled
- Replace pickle with JSON + HMAC-SHA256 state serialization - Add constant-time signature verification - Implement replay attack protection with nonce expiration - Add comprehensive security test suite (54 tests) - Harden token storage with integrity verification Resolves: V-006 (CVSS 8.8)
353 lines
13 KiB
Python
353 lines
13 KiB
Python
"""Specific tests for V-011: Skills Guard Bypass via Path Traversal.
|
|
|
|
This test file focuses on the specific attack vector where malicious skill names
|
|
are used to bypass the skills security guard and access arbitrary files.
|
|
"""
|
|
|
|
import json
|
|
import pytest
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
|
|
class TestV011SkillsGuardBypass:
|
|
"""Tests for V-011 vulnerability fix.
|
|
|
|
V-011: Skills Guard Bypass via Path Traversal
|
|
- CVSS Score: 7.8 (High)
|
|
- Attack Vector: Local/Remote via malicious skill names
|
|
- Description: Path traversal in skill names (e.g., '../../../etc/passwd')
|
|
can bypass skill loading security controls
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def setup_skills_dir(self, tmp_path):
|
|
"""Create a temporary skills directory structure."""
|
|
skills_dir = tmp_path / "skills"
|
|
skills_dir.mkdir()
|
|
|
|
# Create a legitimate skill
|
|
legit_skill = skills_dir / "legit-skill"
|
|
legit_skill.mkdir()
|
|
(legit_skill / "SKILL.md").write_text("""\
|
|
---
|
|
name: legit-skill
|
|
description: A legitimate test skill
|
|
---
|
|
|
|
# Legitimate Skill
|
|
|
|
This skill is safe.
|
|
""")
|
|
|
|
# Create sensitive files outside skills directory
|
|
hermes_dir = tmp_path / ".hermes"
|
|
hermes_dir.mkdir()
|
|
(hermes_dir / ".env").write_text("OPENAI_API_KEY=sk-test12345\nANTHROPIC_API_KEY=sk-ant-test123\n")
|
|
|
|
# Create other sensitive files
|
|
(tmp_path / "secret.txt").write_text("TOP SECRET DATA")
|
|
(tmp_path / "id_rsa").write_text("-----BEGIN OPENSSH PRIVATE KEY-----\ntest-key-data\n-----END OPENSSH PRIVATE KEY-----")
|
|
|
|
return {
|
|
"skills_dir": skills_dir,
|
|
"tmp_path": tmp_path,
|
|
"hermes_dir": hermes_dir,
|
|
}
|
|
|
|
def test_dotdot_traversal_blocked(self, setup_skills_dir):
|
|
"""Basic '../' traversal should be blocked."""
|
|
from tools.skills_tool import skill_view
|
|
|
|
skills_dir = setup_skills_dir["skills_dir"]
|
|
|
|
with patch("tools.skills_tool.SKILLS_DIR", skills_dir):
|
|
# Try to access secret.txt using traversal
|
|
result = json.loads(skill_view("../secret.txt"))
|
|
assert result["success"] is False
|
|
assert "traversal" in result.get("error", "").lower() or "security_error" in result
|
|
|
|
def test_deep_traversal_blocked(self, setup_skills_dir):
|
|
"""Deep traversal '../../../' should be blocked."""
|
|
from tools.skills_tool import skill_view
|
|
|
|
skills_dir = setup_skills_dir["skills_dir"]
|
|
|
|
with patch("tools.skills_tool.SKILLS_DIR", skills_dir):
|
|
# Try deep traversal to reach tmp_path parent
|
|
result = json.loads(skill_view("../../../secret.txt"))
|
|
assert result["success"] is False
|
|
|
|
def test_traversal_with_category_blocked(self, setup_skills_dir):
|
|
"""Traversal within category path should be blocked."""
|
|
from tools.skills_tool import skill_view
|
|
|
|
skills_dir = setup_skills_dir["skills_dir"]
|
|
|
|
# Create category structure
|
|
category_dir = skills_dir / "mlops"
|
|
category_dir.mkdir()
|
|
skill_dir = category_dir / "test-skill"
|
|
skill_dir.mkdir()
|
|
(skill_dir / "SKILL.md").write_text("# Test Skill")
|
|
|
|
with patch("tools.skills_tool.SKILLS_DIR", skills_dir):
|
|
# Try traversal from within category
|
|
result = json.loads(skill_view("mlops/../../secret.txt"))
|
|
assert result["success"] is False
|
|
|
|
def test_home_directory_expansion_blocked(self, setup_skills_dir):
|
|
"""Home directory expansion '~/' should be blocked."""
|
|
from tools.skills_tool import skill_view
|
|
from agent.skill_commands import _load_skill_payload
|
|
|
|
skills_dir = setup_skills_dir["skills_dir"]
|
|
|
|
with patch("tools.skills_tool.SKILLS_DIR", skills_dir):
|
|
# Test skill_view
|
|
result = json.loads(skill_view("~/.hermes/.env"))
|
|
assert result["success"] is False
|
|
|
|
# Test _load_skill_payload
|
|
payload = _load_skill_payload("~/.hermes/.env")
|
|
assert payload is None
|
|
|
|
def test_absolute_path_blocked(self, setup_skills_dir):
|
|
"""Absolute paths should be blocked."""
|
|
from tools.skills_tool import skill_view
|
|
from agent.skill_commands import _load_skill_payload
|
|
|
|
skills_dir = setup_skills_dir["skills_dir"]
|
|
|
|
with patch("tools.skills_tool.SKILLS_DIR", skills_dir):
|
|
# Test various absolute paths
|
|
for path in ["/etc/passwd", "/root/.ssh/id_rsa", "/.env", "/proc/self/environ"]:
|
|
result = json.loads(skill_view(path))
|
|
assert result["success"] is False, f"Absolute path {path} should be blocked"
|
|
|
|
# Test via _load_skill_payload
|
|
payload = _load_skill_payload("/etc/passwd")
|
|
assert payload is None
|
|
|
|
def test_file_protocol_blocked(self, setup_skills_dir):
|
|
"""File protocol URLs should be blocked."""
|
|
from tools.skills_tool import skill_view
|
|
|
|
skills_dir = setup_skills_dir["skills_dir"]
|
|
|
|
with patch("tools.skills_tool.SKILLS_DIR", skills_dir):
|
|
result = json.loads(skill_view("file:///etc/passwd"))
|
|
assert result["success"] is False
|
|
|
|
def test_url_encoding_traversal_blocked(self, setup_skills_dir):
|
|
"""URL-encoded traversal attempts should be blocked."""
|
|
from tools.skills_tool import skill_view
|
|
|
|
skills_dir = setup_skills_dir["skills_dir"]
|
|
|
|
with patch("tools.skills_tool.SKILLS_DIR", skills_dir):
|
|
# URL-encoded '../'
|
|
result = json.loads(skill_view("%2e%2e%2fsecret.txt"))
|
|
# This might fail validation due to % character or resolve to a non-existent skill
|
|
assert result["success"] is False or "not found" in result.get("error", "").lower()
|
|
|
|
def test_null_byte_injection_blocked(self, setup_skills_dir):
|
|
"""Null byte injection attempts should be blocked."""
|
|
from tools.skills_tool import skill_view
|
|
from agent.skill_commands import _load_skill_payload
|
|
|
|
skills_dir = setup_skills_dir["skills_dir"]
|
|
|
|
with patch("tools.skills_tool.SKILLS_DIR", skills_dir):
|
|
# Null byte injection to bypass extension checks
|
|
result = json.loads(skill_view("skill.md\x00.py"))
|
|
assert result["success"] is False
|
|
|
|
payload = _load_skill_payload("skill.md\x00.py")
|
|
assert payload is None
|
|
|
|
def test_double_traversal_blocked(self, setup_skills_dir):
|
|
"""Double traversal '....//' should be blocked."""
|
|
from tools.skills_tool import skill_view
|
|
|
|
skills_dir = setup_skills_dir["skills_dir"]
|
|
|
|
with patch("tools.skills_tool.SKILLS_DIR", skills_dir):
|
|
# Double dot encoding
|
|
result = json.loads(skill_view("....//secret.txt"))
|
|
assert result["success"] is False
|
|
|
|
def test_traversal_with_null_in_middle_blocked(self, setup_skills_dir):
|
|
"""Traversal with embedded null bytes should be blocked."""
|
|
from tools.skills_tool import skill_view
|
|
|
|
skills_dir = setup_skills_dir["skills_dir"]
|
|
|
|
with patch("tools.skills_tool.SKILLS_DIR", skills_dir):
|
|
result = json.loads(skill_view("../\x00/../secret.txt"))
|
|
assert result["success"] is False
|
|
|
|
def test_windows_path_traversal_blocked(self, setup_skills_dir):
|
|
"""Windows-style path traversal should be blocked."""
|
|
from tools.skills_tool import skill_view
|
|
|
|
skills_dir = setup_skills_dir["skills_dir"]
|
|
|
|
with patch("tools.skills_tool.SKILLS_DIR", skills_dir):
|
|
# Windows-style paths
|
|
for path in ["..\\secret.txt", "..\\..\\secret.txt", "C:\\secret.txt"]:
|
|
result = json.loads(skill_view(path))
|
|
assert result["success"] is False, f"Windows path {path} should be blocked"
|
|
|
|
def test_mixed_separator_traversal_blocked(self, setup_skills_dir):
|
|
"""Mixed separator traversal should be blocked."""
|
|
from tools.skills_tool import skill_view
|
|
|
|
skills_dir = setup_skills_dir["skills_dir"]
|
|
|
|
with patch("tools.skills_tool.SKILLS_DIR", skills_dir):
|
|
# Mixed forward and back slashes
|
|
result = json.loads(skill_view("../\\../secret.txt"))
|
|
assert result["success"] is False
|
|
|
|
def test_legitimate_skill_with_hyphens_works(self, setup_skills_dir):
|
|
"""Legitimate skill names with hyphens should work."""
|
|
from tools.skills_tool import skill_view
|
|
from agent.skill_commands import _load_skill_payload
|
|
|
|
skills_dir = setup_skills_dir["skills_dir"]
|
|
|
|
with patch("tools.skills_tool.SKILLS_DIR", skills_dir):
|
|
# Test legitimate skill
|
|
result = json.loads(skill_view("legit-skill"))
|
|
assert result["success"] is True
|
|
assert result.get("name") == "legit-skill"
|
|
|
|
# Test via _load_skill_payload
|
|
payload = _load_skill_payload("legit-skill")
|
|
assert payload is not None
|
|
|
|
def test_legitimate_skill_with_underscores_works(self, setup_skills_dir):
|
|
"""Legitimate skill names with underscores should work."""
|
|
from tools.skills_tool import skill_view
|
|
|
|
skills_dir = setup_skills_dir["skills_dir"]
|
|
|
|
# Create skill with underscore
|
|
skill_dir = skills_dir / "my_skill"
|
|
skill_dir.mkdir()
|
|
(skill_dir / "SKILL.md").write_text("""\
|
|
---
|
|
name: my_skill
|
|
description: Test skill
|
|
---
|
|
|
|
# My Skill
|
|
""")
|
|
|
|
with patch("tools.skills_tool.SKILLS_DIR", skills_dir):
|
|
result = json.loads(skill_view("my_skill"))
|
|
assert result["success"] is True
|
|
|
|
def test_legitimate_category_skill_works(self, setup_skills_dir):
|
|
"""Legitimate category/skill paths should work."""
|
|
from tools.skills_tool import skill_view
|
|
|
|
skills_dir = setup_skills_dir["skills_dir"]
|
|
|
|
# Create category structure
|
|
category_dir = skills_dir / "mlops"
|
|
category_dir.mkdir()
|
|
skill_dir = category_dir / "axolotl"
|
|
skill_dir.mkdir()
|
|
(skill_dir / "SKILL.md").write_text("""\
|
|
---
|
|
name: axolotl
|
|
description: ML training skill
|
|
---
|
|
|
|
# Axolotl
|
|
""")
|
|
|
|
with patch("tools.skills_tool.SKILLS_DIR", skills_dir):
|
|
result = json.loads(skill_view("mlops/axolotl"))
|
|
assert result["success"] is True
|
|
assert result.get("name") == "axolotl"
|
|
|
|
|
|
class TestSkillViewFilePathSecurity:
|
|
"""Tests for file_path parameter security in skill_view."""
|
|
|
|
@pytest.fixture
|
|
def setup_skill_with_files(self, tmp_path):
|
|
"""Create a skill with supporting files."""
|
|
skills_dir = tmp_path / "skills"
|
|
skills_dir.mkdir()
|
|
|
|
skill_dir = skills_dir / "test-skill"
|
|
skill_dir.mkdir()
|
|
(skill_dir / "SKILL.md").write_text("# Test Skill")
|
|
|
|
# Create references directory
|
|
refs = skill_dir / "references"
|
|
refs.mkdir()
|
|
(refs / "api.md").write_text("# API Documentation")
|
|
|
|
# Create secret file outside skill
|
|
(tmp_path / "secret.txt").write_text("SECRET")
|
|
|
|
return {"skills_dir": skills_dir, "skill_dir": skill_dir, "tmp_path": tmp_path}
|
|
|
|
def test_file_path_traversal_blocked(self, setup_skill_with_files):
|
|
"""Path traversal in file_path parameter should be blocked."""
|
|
from tools.skills_tool import skill_view
|
|
|
|
skills_dir = setup_skill_with_files["skills_dir"]
|
|
|
|
with patch("tools.skills_tool.SKILLS_DIR", skills_dir):
|
|
result = json.loads(skill_view("test-skill", file_path="../../secret.txt"))
|
|
assert result["success"] is False
|
|
assert "traversal" in result.get("error", "").lower()
|
|
|
|
def test_file_path_absolute_blocked(self, setup_skill_with_files):
|
|
"""Absolute paths in file_path should be handled safely."""
|
|
from tools.skills_tool import skill_view
|
|
|
|
skills_dir = setup_skill_with_files["skills_dir"]
|
|
|
|
with patch("tools.skills_tool.SKILLS_DIR", skills_dir):
|
|
# Absolute paths should be rejected
|
|
result = json.loads(skill_view("test-skill", file_path="/etc/passwd"))
|
|
assert result["success"] is False
|
|
|
|
def test_legitimate_file_path_works(self, setup_skill_with_files):
|
|
"""Legitimate file paths within skill should work."""
|
|
from tools.skills_tool import skill_view
|
|
|
|
skills_dir = setup_skill_with_files["skills_dir"]
|
|
|
|
with patch("tools.skills_tool.SKILLS_DIR", skills_dir):
|
|
result = json.loads(skill_view("test-skill", file_path="references/api.md"))
|
|
assert result["success"] is True
|
|
assert "API Documentation" in result.get("content", "")
|
|
|
|
|
|
class TestSecurityLogging:
|
|
"""Tests for security event logging."""
|
|
|
|
def test_traversal_attempt_logged(self, tmp_path, caplog):
|
|
"""Path traversal attempts should be logged as warnings."""
|
|
import logging
|
|
from tools.skills_tool import skill_view
|
|
|
|
skills_dir = tmp_path / "skills"
|
|
skills_dir.mkdir()
|
|
|
|
with patch("tools.skills_tool.SKILLS_DIR", skills_dir):
|
|
with caplog.at_level(logging.WARNING):
|
|
result = json.loads(skill_view("../../../etc/passwd"))
|
|
assert result["success"] is False
|
|
# Check that a warning was logged
|
|
assert any("security" in record.message.lower() or "traversal" in record.message.lower()
|
|
for record in caplog.records)
|